From 74ee2aff33fd4db3a4c738949b5280855f7a7d58 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 19 Jul 2022 18:15:12 +0600 Subject: [PATCH] SponsorBlock API support added (cached works too) Grouped `helpers` folder snippets into appropriate sections --- lib/components/Album/AlbumCard.dart | 11 +- lib/components/Album/AlbumView.dart | 13 +- lib/components/Artist/ArtistProfile.dart | 24 +- lib/components/Home/Sidebar.dart | 5 +- lib/components/Library/UserAlbums.dart | 5 +- lib/components/Login/LoginForm.dart | 5 +- lib/components/Lyrics/Lyrics.dart | 5 +- lib/components/Lyrics/SyncedLyrics.dart | 8 +- lib/components/Player/Player.dart | 4 +- lib/components/Player/PlayerControls.dart | 16 +- lib/components/Player/PlayerQueue.dart | 11 +- lib/components/Player/PlayerTrackDetails.dart | 4 +- lib/components/Player/PlayerView.dart | 7 +- lib/components/Playlist/PlaylistCard.dart | 6 +- lib/components/Playlist/PlaylistView.dart | 9 +- lib/components/Search/Search.dart | 22 +- lib/components/Settings/Settings.dart | 15 +- .../Shared/DownloadTrackButton.dart | 8 +- .../Shared/TrackCollectionView.dart | 6 +- lib/components/Shared/TrackTile.dart | 5 +- lib/components/Shared/TracksTableView.dart | 8 +- lib/entities/CacheTrack.dart | 34 +- lib/entities/CacheTrack.g.dart | 45 ++- lib/helpers/artist-to-string.dart | 5 - lib/helpers/artists-to-clickable-artists.dart | 29 -- lib/helpers/contains-text-in-bracket.dart | 7 - lib/helpers/get-random-element.dart | 6 - lib/helpers/getLyrics.dart | 166 -------- lib/helpers/image-to-url-string.dart | 9 - lib/helpers/oauth-login.dart | 64 ---- lib/helpers/readable-number.dart | 13 - lib/helpers/search-youtube.dart | 142 ------- lib/helpers/server_ipc.dart | 44 --- lib/helpers/simple-album-to-album.dart | 19 - lib/helpers/simple-track-to-track.dart | 23 -- lib/helpers/timed-lyrics.dart | 122 ------ lib/helpers/zero-pad-num-str.dart | 3 - lib/main.dart | 3 +- lib/models/LyricsModels.dart | 24 ++ lib/models/SpotubeTrack.dart | 6 + lib/provider/Playback.dart | 348 ++++++++++------- lib/provider/SpotifyDI.dart | 4 +- lib/provider/SpotifyRequests.dart | 13 +- lib/provider/UserPreferences.dart | 18 +- lib/services/LinuxAudioService.dart | 8 +- lib/utils/primitive_utils.dart | 37 ++ lib/utils/service_utils.dart | 359 ++++++++++++++++++ lib/utils/type_conversion_utils.dart | 84 ++++ 48 files changed, 942 insertions(+), 890 deletions(-) delete mode 100644 lib/helpers/artist-to-string.dart delete mode 100644 lib/helpers/artists-to-clickable-artists.dart delete mode 100644 lib/helpers/contains-text-in-bracket.dart delete mode 100644 lib/helpers/get-random-element.dart delete mode 100644 lib/helpers/getLyrics.dart delete mode 100644 lib/helpers/image-to-url-string.dart delete mode 100644 lib/helpers/oauth-login.dart delete mode 100644 lib/helpers/readable-number.dart delete mode 100644 lib/helpers/search-youtube.dart delete mode 100644 lib/helpers/server_ipc.dart delete mode 100644 lib/helpers/simple-album-to-album.dart delete mode 100644 lib/helpers/simple-track-to-track.dart delete mode 100644 lib/helpers/timed-lyrics.dart delete mode 100644 lib/helpers/zero-pad-num-str.dart create mode 100644 lib/models/LyricsModels.dart create mode 100644 lib/utils/primitive_utils.dart create mode 100644 lib/utils/service_utils.dart create mode 100644 lib/utils/type_conversion_utils.dart diff --git a/lib/components/Album/AlbumCard.dart b/lib/components/Album/AlbumCard.dart index 7785d84f..041c81c6 100644 --- a/lib/components/Album/AlbumCard.dart +++ b/lib/components/Album/AlbumCard.dart @@ -3,13 +3,11 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/PlaybuttonCard.dart'; -import 'package:spotube/helpers/artist-to-string.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/simple-track-to-track.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumCard extends HookConsumerWidget { final Album album; @@ -23,14 +21,14 @@ class AlbumCard extends HookConsumerWidget { final int marginH = useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); return PlaybuttonCard( - imageUrl: imageToUrlString(album.images), + imageUrl: TypeConversionUtils.image_X_UrlString(album.images), margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), isPlaying: playback.playlist?.id == album.id, isLoading: playback.status == PlaybackStatus.loading && playback.playlist?.id == album.id, title: album.name!, description: - "Album • ${artistsToString(album.artists ?? [])}", + "Album • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", onTap: () { GoRouter.of(context).push("/album/${album.id}", extra: album); }, @@ -38,7 +36,8 @@ class AlbumCard extends HookConsumerWidget { SpotifyApi spotify = ref.read(spotifyProvider); if (isPlaylistPlaying) return; List tracks = (await spotify.albums.getTracks(album.id!).all()) - .map((track) => simpleTrackToTrack(track, album)) + .map((track) => + TypeConversionUtils.simpleTrack_X_Track(track, album)) .toList(); if (tracks.isEmpty) return; diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart index deca5aac..4c34d735 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -5,8 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/TrackCollectionView.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/simple-track-to-track.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Playback.dart'; @@ -27,7 +26,7 @@ class AlbumView extends HookConsumerWidget { tracks: tracks, id: album.id!, name: album.name!, - thumbnail: imageToUrlString(album.images), + thumbnail: TypeConversionUtils.image_X_UrlString(album.images), ), tracks.indexWhere((s) => s.id == currentTrack?.id), ); @@ -49,8 +48,9 @@ class AlbumView extends HookConsumerWidget { final albumSavedSnapshot = ref.watch(albumIsSavedForCurrentUserQuery(album.id!)); - final albumArt = - useMemoized(() => imageToUrlString(album.images), [album.images]); + final albumArt = useMemoized( + () => TypeConversionUtils.image_X_UrlString(album.images), + [album.images]); return TrackCollectionView( id: album.id!, @@ -66,7 +66,8 @@ class AlbumView extends HookConsumerWidget { playPlaylist( playback, tracksSnapshot.asData!.value - .map((track) => simpleTrackToTrack(track, album)) + .map((track) => + TypeConversionUtils.simpleTrack_X_Track(track, album)) .toList(), currentTrack: track, ); diff --git a/lib/components/Artist/ArtistProfile.dart b/lib/components/Artist/ArtistProfile.dart index 29314fd3..f5a94da8 100644 --- a/lib/components/Artist/ArtistProfile.dart +++ b/lib/components/Artist/ArtistProfile.dart @@ -10,9 +10,6 @@ import 'package:spotube/components/Artist/ArtistCard.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerArtistProfile.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/readable-number.dart'; -import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; @@ -20,6 +17,8 @@ import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistProfile extends HookConsumerWidget { final String artistId; @@ -80,7 +79,7 @@ class ArtistProfile extends HookConsumerWidget { CircleAvatar( radius: avatarWidth, backgroundImage: CachedNetworkImageProvider( - imageToUrlString(data.images), + TypeConversionUtils.image_X_UrlString(data.images), ), ), Padding( @@ -106,7 +105,7 @@ class ArtistProfile extends HookConsumerWidget { : textTheme.headline2, ), Text( - "${toReadableNumber(data.followers!.total!.toDouble())} followers", + "${PrimitiveUtils.toReadableNumber(data.followers!.total!.toDouble())} followers", style: breakpoint.isSm ? textTheme.bodyText1 : textTheme.headline5, @@ -193,7 +192,8 @@ class ArtistProfile extends HookConsumerWidget { tracks: tracks, id: data.id!, name: "${data.name!} To Tracks", - thumbnail: imageToUrlString(data.images), + thumbnail: TypeConversionUtils.image_X_UrlString( + data.images), ), tracks.indexWhere((s) => s.id == currentTrack?.id), ); @@ -230,11 +230,13 @@ class ArtistProfile extends HookConsumerWidget { ), ...topTracks.toList().asMap().entries.map((track) { String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - String? thumbnailUrl = imageToUrlString( - track.value.album?.images, - index: - (track.value.album?.images?.length ?? 1) - 1); + "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + String? thumbnailUrl = + TypeConversionUtils.image_X_UrlString( + track.value.album?.images, + index: + (track.value.album?.images?.length ?? 1) - + 1); return TrackTile( playback, duration: duration, diff --git a/lib/components/Home/Sidebar.dart b/lib/components/Home/Sidebar.dart index 243f0d88..87f2e341 100644 --- a/lib/components/Home/Sidebar.dart +++ b/lib/components/Home/Sidebar.dart @@ -4,13 +4,13 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter/material.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/sideBarTiles.dart'; import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class Sidebar extends HookConsumerWidget { final int selectedIndex; @@ -115,7 +115,8 @@ class Sidebar extends HookConsumerWidget { builder: (context) { final data = meSnapshot.asData?.value; - final avatarImg = imageToUrlString(data?.images, + final avatarImg = TypeConversionUtils.image_X_UrlString( + data?.images, index: (data?.images?.length ?? 1) - 1); if (extended.value) { return Padding( diff --git a/lib/components/Library/UserAlbums.dart b/lib/components/Library/UserAlbums.dart index 87bd3413..7055a596 100644 --- a/lib/components/Library/UserAlbums.dart +++ b/lib/components/Library/UserAlbums.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; -import 'package:spotube/helpers/simple-album-to-album.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class UserAlbums extends ConsumerWidget { const UserAlbums({Key? key}) : super(key: key); @@ -21,7 +21,8 @@ class UserAlbums extends ConsumerWidget { runSpacing: 20, // gap between lines alignment: WrapAlignment.center, children: data - .map((album) => AlbumCard(simpleAlbumToAlbum(album))) + .map((album) => + AlbumCard(TypeConversionUtils.simpleAlbum_X_Album(album))) .toList(), ), ), diff --git a/lib/components/Login/LoginForm.dart b/lib/components/Login/LoginForm.dart index 3131ec8f..c315b410 100644 --- a/lib/components/Login/LoginForm.dart +++ b/lib/components/Login/LoginForm.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/helpers/oauth-login.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Auth.dart'; +import 'package:spotube/utils/service_utils.dart'; class LoginForm extends HookConsumerWidget { final void Function()? onDone; @@ -25,7 +24,7 @@ class LoginForm extends HookConsumerWidget { clientSecretController.value.text == "") { fieldError.value = true; } - await oauthLogin( + await ServiceUtils.oauthLogin( ref.read(authProvider), clientId: clientIdController.value.text, clientSecret: clientSecretController.value.text, diff --git a/lib/components/Lyrics/Lyrics.dart b/lib/components/Lyrics/Lyrics.dart index 9ea4b853..15bc3415 100644 --- a/lib/components/Lyrics/Lyrics.dart +++ b/lib/components/Lyrics/Lyrics.dart @@ -3,10 +3,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; -import 'package:spotube/helpers/artist-to-string.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class Lyrics extends HookConsumerWidget { final Color? titleBarForegroundColor; @@ -36,7 +36,8 @@ class Lyrics extends HookConsumerWidget { ), Center( child: Text( - artistsToString(playback.track?.artists ?? []), + TypeConversionUtils.artists_X_String( + playback.track?.artists ?? []), style: breakpoint >= Breakpoints.md ? textTheme.headline5 : textTheme.headline6, diff --git a/lib/components/Lyrics/SyncedLyrics.dart b/lib/components/Lyrics/SyncedLyrics.dart index 0276f59d..edee3676 100644 --- a/lib/components/Lyrics/SyncedLyrics.dart +++ b/lib/components/Lyrics/SyncedLyrics.dart @@ -4,15 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart'; import 'package:spotube/components/Lyrics/LyricDelayAdjustDialog.dart'; import 'package:spotube/components/Lyrics/Lyrics.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/SpotubeMarqueeText.dart'; -import 'package:spotube/helpers/artist-to-string.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useAutoScrollController.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useCustomStatusBarColor.dart'; @@ -21,6 +18,7 @@ import 'package:spotube/hooks/useSyncedLyrics.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; final lyricDelayState = StateProvider( (ref) { @@ -109,7 +107,7 @@ class SyncedLyrics extends HookConsumerWidget { // when synced lyrics not found, fallback to GeniusLyrics String albumArt = useMemoized( - () => imageToUrlString( + () => TypeConversionUtils.image_X_UrlString( playback.track?.album?.images, index: (playback.track?.album?.images?.length ?? 1) - 1, ), @@ -198,7 +196,7 @@ class SyncedLyrics extends HookConsumerWidget { ), Center( child: Text( - artistsToString( + TypeConversionUtils.artists_X_String( playback.track?.artists ?? []), style: breakpoint >= Breakpoints.md ? textTheme.headline5 diff --git a/lib/components/Player/Player.dart b/lib/components/Player/Player.dart index a41fad19..505ebd2d 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -4,11 +4,11 @@ import 'package:spotube/components/Player/PlayerActions.dart'; import 'package:spotube/components/Player/PlayerOverlay.dart'; import 'package:spotube/components/Player/PlayerTrackDetails.dart'; import 'package:spotube/components/Player/PlayerControls.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:flutter/material.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class Player extends HookConsumerWidget { Player({Key? key}) : super(key: key); @@ -21,7 +21,7 @@ class Player extends HookConsumerWidget { final breakpoint = useBreakpoints(); String albumArt = useMemoized( - () => imageToUrlString( + () => TypeConversionUtils.image_X_UrlString( playback.track?.album?.images, index: (playback.track?.album?.images?.length ?? 1) - 1, ), diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index 17f28da6..327895e8 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/playback.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/utils/primitive_utils.dart'; class PlayerControls extends HookConsumerWidget { final Color? iconColor; @@ -35,15 +35,17 @@ class PlayerControls extends HookConsumerWidget { StreamBuilder( stream: playback.player.onPositionChanged, builder: (context, snapshot) { - final totalMinutes = - zeroPadNumStr(duration.inMinutes.remainder(60)); - final totalSeconds = - zeroPadNumStr(duration.inSeconds.remainder(60)); + final totalMinutes = PrimitiveUtils.zeroPadNumStr( + duration.inMinutes.remainder(60)); + final totalSeconds = PrimitiveUtils.zeroPadNumStr( + duration.inSeconds.remainder(60)); final currentMinutes = snapshot.hasData - ? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60)) + ? PrimitiveUtils.zeroPadNumStr( + snapshot.data!.inMinutes.remainder(60)) : "00"; final currentSeconds = snapshot.hasData - ? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60)) + ? PrimitiveUtils.zeroPadNumStr( + snapshot.data!.inSeconds.remainder(60)) : "00"; final sliderMax = duration.inSeconds; diff --git a/lib/components/Player/PlayerQueue.dart b/lib/components/Player/PlayerQueue.dart index c8fb8b81..aafdbd59 100644 --- a/lib/components/Player/PlayerQueue.dart +++ b/lib/components/Player/PlayerQueue.dart @@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/components/Shared/NotFound.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/useAutoScrollController.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; @@ -100,7 +100,7 @@ class PlayerQueue extends HookConsumerWidget { itemBuilder: (context, i) { final track = tracks.asMap().entries.elementAt(i); String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; return AutoScrollTag( key: ValueKey(i), controller: controller, @@ -111,8 +111,9 @@ class PlayerQueue extends HookConsumerWidget { playback, track: track, duration: duration, - thumbnailUrl: - imageToUrlString(track.value.album?.images), + thumbnailUrl: TypeConversionUtils.image_X_UrlString( + track.value.album?.images, + ), isActive: playback.track?.id == track.value.id, onTrackPlayButtonPressed: (currentTrack) async { if (playback.track?.id == track.value.id) return; diff --git a/lib/components/Player/PlayerTrackDetails.dart b/lib/components/Player/PlayerTrackDetails.dart index 83370719..b66f6816 100644 --- a/lib/components/Player/PlayerTrackDetails.dart +++ b/lib/components/Player/PlayerTrackDetails.dart @@ -1,9 +1,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/helpers/artists-to-clickable-artists.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { final String? albumArt; @@ -61,7 +61,7 @@ class PlayerTrackDetails extends HookConsumerWidget { .bodyText1 ?.copyWith(fontWeight: FontWeight.bold, color: color), ), - artistsToClickableArtists( + TypeConversionUtils.artists_X_ClickableArtists( playback.track?.artists ?? [], ) ], diff --git a/lib/components/Player/PlayerView.dart b/lib/components/Player/PlayerView.dart index 99699d73..c7b253c7 100644 --- a/lib/components/Player/PlayerView.dart +++ b/lib/components/Player/PlayerView.dart @@ -10,12 +10,11 @@ import 'package:spotube/components/Player/PlayerActions.dart'; import 'package:spotube/components/Player/PlayerControls.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/SpotubeMarqueeText.dart'; -import 'package:spotube/helpers/artists-to-clickable-artists.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useCustomStatusBarColor.dart'; import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerView extends HookConsumerWidget { const PlayerView({ @@ -39,7 +38,7 @@ class PlayerView extends HookConsumerWidget { }, [breakpoint]); String albumArt = useMemoized( - () => imageToUrlString( + () => TypeConversionUtils.image_X_UrlString( currentTrack?.album?.images, index: (currentTrack?.album?.images?.length ?? 1) - 1, ), @@ -100,7 +99,7 @@ class PlayerView extends HookConsumerWidget { style: Theme.of(context).textTheme.headline5, ), ), - artistsToClickableArtists( + TypeConversionUtils.artists_X_ClickableArtists( currentTrack?.artists ?? [], textStyle: Theme.of(context).textTheme.headline6!.copyWith( diff --git a/lib/components/Playlist/PlaylistCard.dart b/lib/components/Playlist/PlaylistCard.dart index 3c5fcbbb..fd841da7 100644 --- a/lib/components/Playlist/PlaylistCard.dart +++ b/lib/components/Playlist/PlaylistCard.dart @@ -3,11 +3,11 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/PlaybuttonCard.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; @@ -23,7 +23,7 @@ class PlaylistCard extends HookConsumerWidget { return PlaybuttonCard( margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), title: playlist.name!, - imageUrl: imageToUrlString(playlist.images), + imageUrl: TypeConversionUtils.image_X_UrlString(playlist.images), isPlaying: isPlaylistPlaying, isLoading: playback.status == PlaybackStatus.loading && isPlaylistPlaying, onTap: () { @@ -52,7 +52,7 @@ class PlaylistCard extends HookConsumerWidget { tracks: tracks, id: playlist.id!, name: playlist.name!, - thumbnail: imageToUrlString(playlist.images), + thumbnail: TypeConversionUtils.image_X_UrlString(playlist.images), ), ); }, diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index df1be1df..83ab459c 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/TrackCollectionView.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/Logger.dart'; @@ -15,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistView extends HookConsumerWidget { final logger = getLogger(PlaylistView); @@ -32,7 +32,7 @@ class PlaylistView extends HookConsumerWidget { tracks: tracks, id: playlist.id!, name: playlist.name!, - thumbnail: imageToUrlString(playlist.images), + thumbnail: TypeConversionUtils.image_X_UrlString(playlist.images), ), tracks.indexWhere((s) => s.id == currentTrack?.id), ); @@ -54,8 +54,9 @@ class PlaylistView extends HookConsumerWidget { final meSnapshot = ref.watch(currentUserQuery); final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!)); - final titleImage = - useMemoized(() => imageToUrlString(playlist.images), [playlist.images]); + final titleImage = useMemoized( + () => TypeConversionUtils.image_X_UrlString(playlist.images), + [playlist.images]); final color = usePaletteGenerator( context, diff --git a/lib/components/Search/Search.dart b/lib/components/Search/Search.dart index 502719d0..44188fef 100644 --- a/lib/components/Search/Search.dart +++ b/lib/components/Search/Search.dart @@ -7,14 +7,13 @@ import 'package:spotube/components/Artist/ArtistCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/components/Shared/AnonymousFallback.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/simple-album-to-album.dart'; -import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; final searchTermStateProvider = StateProvider((ref) => ""); @@ -104,13 +103,14 @@ class Search extends HookConsumerWidget { ), ...tracks.asMap().entries.map((track) { String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; return TrackTile( playback, track: track, duration: duration, thumbnailUrl: - imageToUrlString(track.value.album?.images), + TypeConversionUtils.image_X_UrlString( + track.value.album?.images), isActive: playback.track?.id == track.value.id, onTrackPlayButtonPressed: (currentTrack) async { var isPlaylistPlaying = @@ -123,8 +123,10 @@ class Search extends HookConsumerWidget { tracks: [currentTrack], id: currentTrack.id!, name: currentTrack.name!, - thumbnail: imageToUrlString( - currentTrack.album?.images), + thumbnail: TypeConversionUtils + .image_X_UrlString( + currentTrack.album?.images, + ), ), ); } else if (isPlaylistPlaying && @@ -148,7 +150,11 @@ class Search extends HookConsumerWidget { controller: albumController, child: Row( children: albums.map((album) { - return AlbumCard(simpleAlbumToAlbum(album)); + return AlbumCard( + TypeConversionUtils.simpleAlbum_X_Album( + album, + ), + ); }).toList(), ), ), diff --git a/lib/components/Settings/Settings.dart b/lib/components/Settings/Settings.dart index bb1cae2f..4cf81865 100644 --- a/lib/components/Settings/Settings.dart +++ b/lib/components/Settings/Settings.dart @@ -5,10 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/Settings/About.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; -import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/models/SpotifyMarkets.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Auth.dart'; +import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:collection/collection.dart'; @@ -186,6 +186,19 @@ class Settings extends HookConsumerWidget { ], ), ), + ListTile( + title: const Text( + "Skip non-music segments (SponsorBlock)", + ), + horizontalTitleGap: 10, + trailing: Switch.adaptive( + activeColor: Theme.of(context).primaryColor, + value: preferences.skipSponsorSegments, + onChanged: (state) { + preferences.setSkipSponsorSegments(state); + }, + ), + ), ListTile( title: const Text("Download lyrics along with the Track"), horizontalTitleGap: 10, diff --git a/lib/components/Shared/DownloadTrackButton.dart b/lib/components/Shared/DownloadTrackButton.dart index d5c2fbf3..e8f5d295 100644 --- a/lib/components/Shared/DownloadTrackButton.dart +++ b/lib/components/Shared/DownloadTrackButton.dart @@ -4,12 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/helpers/artist-to-string.dart'; -import 'package:spotube/helpers/getLyrics.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:path_provider/path_provider.dart' as path_provider; import 'package:path/path.dart' as path; @@ -32,7 +32,7 @@ class DownloadTrackButton extends HookConsumerWidget { final outputFile = useState(null); final downloadFolder = useState(null); String fileName = - "${track?.name} - ${artistsToString(track?.artists ?? [])}"; + "${track?.name} - ${TypeConversionUtils.artists_X_String(track?.artists ?? [])}"; useEffect(() { (() async { @@ -152,7 +152,7 @@ class DownloadTrackButton extends HookConsumerWidget { if (!await outputLyricsFile.exists()) { await outputLyricsFile.create(recursive: true); } - final lyrics = await getLyrics( + final lyrics = await ServiceUtils.getLyrics( playback.track!.name!, playback.track!.artists?.map((s) => s.name).whereNotNull().toList() ?? [], diff --git a/lib/components/Shared/TrackCollectionView.dart b/lib/components/Shared/TrackCollectionView.dart index ddcaba32..55c67efe 100644 --- a/lib/components/Shared/TrackCollectionView.dart +++ b/lib/components/Shared/TrackCollectionView.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TracksTableView.dart'; -import 'package:spotube/helpers/simple-track-to-track.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/hooks/useCustomStatusBarColor.dart'; import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/models/Logger.dart'; @@ -225,7 +225,9 @@ class TrackCollectionView extends HookConsumerWidget { return TracksTableView( tracks is! List ? tracks - .map((track) => simpleTrackToTrack(track, album!)) + .map((track) => + TypeConversionUtils.simpleTrack_X_Track( + track, album!)) .toList() : tracks, onTrackPlayButtonPressed: onPlay, diff --git a/lib/components/Shared/TrackTile.dart b/lib/components/Shared/TrackTile.dart index 03660f2b..40a84f5d 100644 --- a/lib/components/Shared/TrackTile.dart +++ b/lib/components/Shared/TrackTile.dart @@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/LinkText.dart'; -import 'package:spotube/helpers/artists-to-clickable-artists.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useForceUpdate.dart'; import 'package:spotube/models/Logger.dart'; @@ -13,6 +12,7 @@ import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class TrackTile extends HookConsumerWidget { final Playback playback; @@ -235,7 +235,8 @@ class TrackTile extends HookConsumerWidget { ), overflow: TextOverflow.ellipsis, ), - artistsToClickableArtists(track.value.artists ?? [], + TypeConversionUtils.artists_X_ClickableArtists( + track.value.artists ?? [], textStyle: TextStyle( fontSize: breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)), diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index c8c153a7..fad86ea9 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class TracksTableView extends HookConsumerWidget { final void Function(Track currentTrack)? onTrackPlayButtonPressed; @@ -79,12 +79,12 @@ class TracksTableView extends HookConsumerWidget { ], ), ...tracks.asMap().entries.map((track) { - String? thumbnailUrl = imageToUrlString( + String? thumbnailUrl = TypeConversionUtils.image_X_UrlString( track.value.album?.images, index: (track.value.album?.images?.length ?? 1) - 1, ); String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; return TrackTile( playback, playlistId: playlistId, diff --git a/lib/entities/CacheTrack.dart b/lib/entities/CacheTrack.dart index 2a3a93ca..00b2263a 100644 --- a/lib/entities/CacheTrack.dart +++ b/lib/entities/CacheTrack.dart @@ -22,6 +22,25 @@ class CacheTrackEngagement { dislikeCount = engagement.dislikeCount; } +@HiveType(typeId: 3) +class CacheTrackSkipSegment { + @HiveField(0) + late int start; + @HiveField(1) + late int end; + + CacheTrackSkipSegment(); + + CacheTrackSkipSegment.fromJson(Map map) + : start = map["start"].toInt(), + end = map["end"].toInt(); + + Map toJson() { + return Map.castFrom( + {"start": start, "end": end}); + } +} + @HiveType(typeId: 1) class CacheTrack extends HiveObject { @HiveField(0) @@ -57,10 +76,16 @@ class CacheTrack extends HiveObject { @HiveField(10) late String author; + @HiveField(11) + late List? skipSegments; + CacheTrack(); - CacheTrack.fromVideo(Video video, this.mode) - : id = video.id.value, + CacheTrack.fromVideo( + Video video, + this.mode, { + required List> skipSegments, + }) : id = video.id.value, title = video.title, author = video.author, channelId = video.channelId.value, @@ -69,5 +94,8 @@ class CacheTrack extends HiveObject { description = video.description, duration = video.duration.toString(), keywords = video.keywords, - engagement = CacheTrackEngagement.fromEngagement(video.engagement); + engagement = CacheTrackEngagement.fromEngagement(video.engagement), + skipSegments = skipSegments + .map((segment) => CacheTrackSkipSegment.fromJson(segment)) + .toList(); } diff --git a/lib/entities/CacheTrack.g.dart b/lib/entities/CacheTrack.g.dart index e067fd79..423d767e 100644 --- a/lib/entities/CacheTrack.g.dart +++ b/lib/entities/CacheTrack.g.dart @@ -45,6 +45,42 @@ class CacheTrackEngagementAdapter extends TypeAdapter { typeId == other.typeId; } +class CacheTrackSkipSegmentAdapter extends TypeAdapter { + @override + final int typeId = 3; + + @override + CacheTrackSkipSegment read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CacheTrackSkipSegment() + ..start = fields[0] as int + ..end = fields[1] as int; + } + + @override + void write(BinaryWriter writer, CacheTrackSkipSegment obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.start) + ..writeByte(1) + ..write(obj.end); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CacheTrackSkipSegmentAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + class CacheTrackAdapter extends TypeAdapter { @override final int typeId = 1; @@ -66,13 +102,14 @@ class CacheTrackAdapter extends TypeAdapter { ..keywords = (fields[7] as List?)?.cast() ..engagement = fields[8] as CacheTrackEngagement ..mode = fields[9] as String - ..author = fields[10] as String; + ..author = fields[10] as String + ..skipSegments = (fields[11] as List?)?.cast(); } @override void write(BinaryWriter writer, CacheTrack obj) { writer - ..writeByte(11) + ..writeByte(12) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -94,7 +131,9 @@ class CacheTrackAdapter extends TypeAdapter { ..writeByte(9) ..write(obj.mode) ..writeByte(10) - ..write(obj.author); + ..write(obj.author) + ..writeByte(11) + ..write(obj.skipSegments); } @override diff --git a/lib/helpers/artist-to-string.dart b/lib/helpers/artist-to-string.dart deleted file mode 100644 index 6f3b30f0..00000000 --- a/lib/helpers/artist-to-string.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:spotify/spotify.dart'; - -String artistsToString(List artists) { - return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); -} diff --git a/lib/helpers/artists-to-clickable-artists.dart b/lib/helpers/artists-to-clickable-artists.dart deleted file mode 100644 index aa55ab02..00000000 --- a/lib/helpers/artists-to-clickable-artists.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/Shared/LinkText.dart'; - -Widget artistsToClickableArtists( - List artists, { - WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, - WrapAlignment mainAxisAlignment = WrapAlignment.center, - TextStyle textStyle = const TextStyle(), -}) { - return Wrap( - crossAxisAlignment: crossAxisAlignment, - alignment: mainAxisAlignment, - children: artists - .asMap() - .entries - .map( - (artist) => LinkText( - (artist.key != artists.length - 1) - ? "${artist.value.name}, " - : artist.value.name!, - "/artist/${artist.value.id}", - overflow: TextOverflow.ellipsis, - style: textStyle, - ), - ) - .toList(), - ); -} diff --git a/lib/helpers/contains-text-in-bracket.dart b/lib/helpers/contains-text-in-bracket.dart deleted file mode 100644 index cc83b99b..00000000 --- a/lib/helpers/contains-text-in-bracket.dart +++ /dev/null @@ -1,7 +0,0 @@ -bool containsTextInBracket(String matcher, String text) { - final allMatches = RegExp(r"(?<=\().+?(?=\))").allMatches(matcher); - if (allMatches.isEmpty) return false; - return allMatches - .map((e) => e.group(0)) - .every((match) => match?.contains(text) ?? false); -} diff --git a/lib/helpers/get-random-element.dart b/lib/helpers/get-random-element.dart deleted file mode 100644 index 47390678..00000000 --- a/lib/helpers/get-random-element.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'dart:math'; - -final Random _random = Random(); -T getRandomElement(List list) { - return list[_random.nextInt(list.length)]; -} diff --git a/lib/helpers/getLyrics.dart b/lib/helpers/getLyrics.dart deleted file mode 100644 index 49adff9d..00000000 --- a/lib/helpers/getLyrics.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:convert'; -import 'package:html/parser.dart' as parser; -import 'package:html/dom.dart'; -import 'package:http/http.dart' as http; -import 'package:spotube/helpers/get-random-element.dart'; -import 'package:spotube/models/Logger.dart'; -import 'package:spotube/models/generated_secrets.dart'; -import 'package:collection/collection.dart'; - -final logger = getLogger("GetLyrics"); - -String clearArtistsOfTitle(String title, List artists) { - return title - .replaceAll(RegExp(artists.join("|"), caseSensitive: false), "") - .trim(); -} - -String getTitle( - String title, { - List artists = const [], - bool onlyCleanArtist = false, -}) { - final match = RegExp(r"(?<=\().+?(?=\))").firstMatch(title)?.group(0); - final artistInBracket = - artists.any((artist) => match?.contains(artist) ?? false); - - if (artistInBracket) { - title = title.replaceAll( - RegExp(" *\\([^)]*\\) *"), - '', - ); - } - - title = clearArtistsOfTitle(title, artists); - if (onlyCleanArtist) { - artists = []; - } - - return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}" - .toLowerCase() - .replaceAll(RegExp(" *\\[[^\\]]*]"), '') - .replaceAll(RegExp("feat.|ft."), '') - .replaceAll(RegExp("\\s+"), ' ') - .trim(); -} - -Future extractLyrics(Uri url) async { - try { - var response = await http.get(url); - - Document document = parser.parse(response.body); - var lyrics = document.querySelector('div.lyrics')?.text.trim(); - if (lyrics == null) { - lyrics = ""; - document - .querySelectorAll("div[class^=\"Lyrics__Container\"]") - .forEach((element) { - if (element.text.trim().isNotEmpty) { - var snippet = element.innerHtml.replaceAll("
", "\n").replaceAll( - RegExp("<(?!\\s*br\\s*\\/?)[^>]+>", caseSensitive: false), - "", - ); - var el = document.createElement("textarea"); - el.innerHtml = snippet; - lyrics = "$lyrics${el.text.trim()}\n\n"; - } - }); - } - - return lyrics; - } catch (e, stack) { - logger.e("extractLyrics", e, stack); - rethrow; - } -} - -Future searchSong( - String title, - List artist, { - String? apiKey, - bool optimizeQuery = false, - bool authHeader = false, -}) async { - try { - if (apiKey == "" || apiKey == null) { - apiKey = getRandomElement(lyricsSecrets); - } - const searchUrl = 'https://api.genius.com/search?q='; - String song = - optimizeQuery ? getTitle(title, artists: artist) : "$title $artist"; - - String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; - Map headers = {"Authorization": 'Bearer $apiKey'}; - final response = await http.get( - Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), - headers: authHeader ? headers : null, - ); - Map data = jsonDecode(response.body)["response"]; - if (data["hits"]?.length == 0) return null; - List results = data["hits"]?.map((val) { - return { - "id": val["result"]["id"], - "full_title": val["result"]["full_title"], - "albumArt": val["result"]["song_art_image_url"], - "url": val["result"]["url"], - "author": val["result"]["primary_artist"]["name"], - }; - }).toList(); - return results; - } catch (e, stack) { - logger.e("searchSong", e, stack); - rethrow; - } -} - -Future getLyrics( - String title, - List artists, { - required String apiKey, - bool optimizeQuery = false, - bool authHeader = false, -}) async { - try { - final results = await searchSong( - title, - artists, - apiKey: apiKey, - optimizeQuery: optimizeQuery, - authHeader: authHeader, - ); - if (results == null) return null; - title = getTitle( - title, - artists: artists, - onlyCleanArtist: true, - ).trim(); - final ratedLyrics = results.map((result) { - final gTitle = (result["full_title"] as String).toLowerCase(); - int points = 0; - final hasTitle = gTitle.contains(title); - final hasAllArtists = - artists.every((artist) => gTitle.contains(artist.toLowerCase())); - final String lyricAuthor = result["author"].toLowerCase(); - final fromOriginalAuthor = - lyricAuthor.contains(artists.first.toLowerCase()); - - for (final criteria in [ - hasTitle, - hasAllArtists, - fromOriginalAuthor, - ]) { - if (criteria) points++; - } - return {"result": result, "points": points}; - }).sorted( - (a, b) => ((a["points"] as int).compareTo(a["points"] as int)), - ); - final worthyOne = ratedLyrics.first["result"]; - - String? lyrics = await extractLyrics(Uri.parse(worthyOne["url"])); - return lyrics; - } catch (e, stack) { - logger.e("getLyrics", e, stack); - return null; - } -} diff --git a/lib/helpers/image-to-url-string.dart b/lib/helpers/image-to-url-string.dart deleted file mode 100644 index 52731ff7..00000000 --- a/lib/helpers/image-to-url-string.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:spotify/spotify.dart'; -import 'package:uuid/uuid.dart' show Uuid; - -const uuid = Uuid(); -String imageToUrlString(List? images, {int index = 0}) { - return images != null && images.isNotEmpty - ? images[0].url! - : "https://avatars.dicebear.com/api/bottts/${uuid.v4()}.png"; -} diff --git a/lib/helpers/oauth-login.dart b/lib/helpers/oauth-login.dart deleted file mode 100644 index 12cd5499..00000000 --- a/lib/helpers/oauth-login.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/Home/Home.dart'; -import 'package:spotube/helpers/server_ipc.dart'; -import 'package:spotube/models/LocalStorageKeys.dart'; -import 'package:spotube/models/Logger.dart'; -import 'package:spotube/provider/Auth.dart'; - -const redirectUri = "http://localhost:4304/auth/spotify/callback"; -final logger = getLogger("OAuthLogin"); - -Future oauthLogin(Auth auth, - {required String clientId, required String clientSecret}) async { - try { - String? accessToken; - String? refreshToken; - DateTime? expiration; - final credentials = SpotifyApiCredentials(clientId, clientSecret); - final grant = SpotifyApi.authorizationCodeGrant(credentials); - - final authUri = grant.getAuthorizationUrl(Uri.parse(redirectUri), - scopes: spotifyScopes); - - final responseUri = await connectIpc(authUri.toString(), redirectUri); - SharedPreferences localStorage = await SharedPreferences.getInstance(); - if (responseUri != null) { - final SpotifyApi spotify = - SpotifyApi.fromAuthCodeGrant(grant, responseUri); - final credentials = await spotify.getCredentials(); - if (credentials.accessToken != null) { - accessToken = credentials.accessToken; - await localStorage.setString( - LocalStorageKeys.accessToken, credentials.accessToken!); - } - if (credentials.refreshToken != null) { - refreshToken = credentials.refreshToken; - await localStorage.setString( - LocalStorageKeys.refreshToken, credentials.refreshToken!); - } - if (credentials.expiration != null) { - expiration = credentials.expiration; - await localStorage.setString(LocalStorageKeys.expiration, - credentials.expiration?.toString() ?? ""); - } - } - - await localStorage.setString(LocalStorageKeys.clientId, clientId); - await localStorage.setString( - LocalStorageKeys.clientSecret, - clientSecret, - ); - - auth.setAuthState( - clientId: clientId, - clientSecret: clientSecret, - accessToken: accessToken, - refreshToken: refreshToken, - expiration: expiration, - ); - } catch (e, stack) { - logger.e("oauthLogin", e, stack); - rethrow; - } -} diff --git a/lib/helpers/readable-number.dart b/lib/helpers/readable-number.dart deleted file mode 100644 index 9f84489c..00000000 --- a/lib/helpers/readable-number.dart +++ /dev/null @@ -1,13 +0,0 @@ -String toReadableNumber(double num) { - if (num > 999 && num < 99999) { - return "${(num / 1000).toStringAsFixed(0)}K"; - } else if (num > 99999 && num < 999999) { - return "${(num / 1000).toStringAsFixed(0)}K"; - } else if (num > 999999 && num < 999999999) { - return "${(num / 1000000).toStringAsFixed(0)}M"; - } else if (num > 999999999) { - return "${(num / 1000000000).toStringAsFixed(0)}B"; - } else { - return num.toString(); - } -} diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart deleted file mode 100644 index a6ae0144..00000000 --- a/lib/helpers/search-youtube.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:io'; -import 'package:hive/hive.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/entities/CacheTrack.dart'; -import 'package:spotube/helpers/contains-text-in-bracket.dart'; -import 'package:spotube/helpers/getLyrics.dart'; -import 'package:spotube/models/Logger.dart'; -import 'package:spotube/models/SpotubeTrack.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -import 'package:collection/collection.dart'; -import 'package:spotube/extensions/list-sort-multiple.dart'; -import 'package:spotube/extensions/yt-video-from-cache-track.dart'; - -enum AudioQuality { - high, - low, -} - -final logger = getLogger("toSpotubeTrack"); -Future toSpotubeTrack({ - required YoutubeExplode youtube, - required Track track, - required String format, - required SpotubeTrackMatchAlgorithm matchAlgorithm, - required AudioQuality audioQuality, - LazyBox? box, -}) async { - final artistsName = - track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ?? - []; - logger.v("[Track Search Artists] $artistsName"); - final mainArtist = artistsName.first; - final featuredArtists = - artistsName.length > 1 ? "feat. " + artistsName.sublist(1).join(" ") : ""; - final title = getTitle( - track.name!, - artists: artistsName, - onlyCleanArtist: true, - ).trim(); - logger.v("[Track Search Title] $title"); - final queryString = format - .replaceAll("\$MAIN_ARTIST", mainArtist) - .replaceAll("\$TITLE", title) - .replaceAll("\$FEATURED_ARTISTS", featuredArtists); - logger.v("[Youtube Search Term] $queryString"); - - Video ytVideo; - final cachedTrack = await box?.get(track.id); - if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) { - logger.v( - "[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}", - ); - ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack); - } else { - VideoSearchList videos = await youtube.search.search(queryString); - if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { - List ratedRankedVideos = videos - .map((video) { - // the find should be lazy thus everything case insensitive - final ytTitle = video.title.toLowerCase(); - final bool hasTitle = ytTitle.contains(title); - final bool hasAllArtists = track.artists?.every( - (artist) => ytTitle.contains(artist.name!.toLowerCase()), - ) ?? - false; - final bool authorIsArtist = - track.artists?.first.name?.toLowerCase() == - video.author.toLowerCase(); - - final bool hasNoLiveInTitle = - !containsTextInBracket(ytTitle, "live"); - - int rate = 0; - for (final el in [ - hasTitle, - hasAllArtists, - if (matchAlgorithm == SpotubeTrackMatchAlgorithm.authenticPopular) - authorIsArtist, - hasNoLiveInTitle, - !video.isLive, - ]) { - if (el) rate++; - } - // can't let pass any non title matching track - if (!hasTitle) rate = rate - 2; - return { - "video": video, - "points": rate, - "views": video.engagement.viewCount, - }; - }) - .toList() - .sortByProperties( - [false, false], - ["points", "views"], - ); - - ytVideo = ratedRankedVideos.first["video"] as Video; - } else { - ytVideo = videos.where((video) => !video.isLive).first; - } - } - - final trackManifest = await youtube.videos.streams.getManifest(ytVideo.id); - - logger.v( - "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", - ); - - final audioManifest = trackManifest.audioOnly.where((info) { - final isMp4a = info.codec.mimeType == "audio/mp4"; - if (Platform.isLinux) { - return !isMp4a; - } else if (Platform.isMacOS || Platform.isIOS) { - return isMp4a; - } else { - return true; - } - }); - - final ytUri = (audioQuality == AudioQuality.high - ? audioManifest.withHighestBitrate() - : audioManifest.sortByBitrate().last) - .url - .toString(); - - // only save when the track isn't available in the cache with same - // matchAlgorithm - if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) { - await box?.put( - track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name)); - } - - return SpotubeTrack.fromTrack( - track: track, - ytTrack: ytVideo, - // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia - // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' - // codec/mimetype for those Platforms - ytUri: ytUri, - ); -} diff --git a/lib/helpers/server_ipc.dart b/lib/helpers/server_ipc.dart deleted file mode 100644 index bc541c21..00000000 --- a/lib/helpers/server_ipc.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:io'; - -import 'package:spotube/models/Logger.dart'; -import 'package:url_launcher/url_launcher.dart'; - -final logger = getLogger("ServerIPC"); - -Future connectIpc(String authUri, String redirectUri) async { - try { - logger.i("[Launching]: $authUri"); - await launchUrl( - Uri.parse(authUri), - mode: LaunchMode.externalApplication, - ); - - HttpServer server = - await HttpServer.bind(InternetAddress.loopbackIPv4, 4304); - logger.i("Server started"); - - await for (HttpRequest request in server) { - if (request.uri.path == "/auth/spotify/callback" && - request.method == "GET") { - String? code = request.uri.queryParameters["code"]; - if (code != null) { - request.response - ..statusCode = HttpStatus.ok - ..write("Authentication successful. Now Go back to Spotube") - ..close(); - return "$redirectUri?code=$code"; - } else { - request.response - ..statusCode = HttpStatus.forbidden - ..write("Authorization failed start over!") - ..close(); - throw Exception("No code provided"); - } - } - } - } catch (e, stack) { - logger.e("connectIpc", e, stack); - rethrow; - } - return null; -} diff --git a/lib/helpers/simple-album-to-album.dart b/lib/helpers/simple-album-to-album.dart deleted file mode 100644 index 19e8f8a6..00000000 --- a/lib/helpers/simple-album-to-album.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:spotify/spotify.dart'; - -Album simpleAlbumToAlbum(AlbumSimple albumSimple) { - Album album = Album(); - album.albumType = albumSimple.albumType; - album.artists = albumSimple.artists; - album.availableMarkets = albumSimple.availableMarkets; - album.externalUrls = albumSimple.externalUrls; - album.href = albumSimple.href; - album.id = albumSimple.id; - album.images = albumSimple.images; - album.name = albumSimple.name; - album.releaseDate = albumSimple.releaseDate; - album.releaseDatePrecision = albumSimple.releaseDatePrecision; - album.tracks = albumSimple.tracks; - album.type = albumSimple.type; - album.uri = albumSimple.uri; - return album; -} diff --git a/lib/helpers/simple-track-to-track.dart b/lib/helpers/simple-track-to-track.dart deleted file mode 100644 index 74c1ba1b..00000000 --- a/lib/helpers/simple-track-to-track.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:spotify/spotify.dart'; - -Track simpleTrackToTrack(TrackSimple trackSmp, AlbumSimple album) { - Track track = Track(); - track.name = trackSmp.name; - track.album = album; - track.artists = trackSmp.artists; - track.availableMarkets = trackSmp.availableMarkets; - track.discNumber = trackSmp.discNumber; - track.durationMs = trackSmp.durationMs; - track.explicit = trackSmp.explicit; - track.externalUrls = trackSmp.externalUrls; - track.href = trackSmp.href; - track.id = trackSmp.id; - track.isPlayable = trackSmp.isPlayable; - track.linkedFrom = trackSmp.linkedFrom; - track.name = trackSmp.name; - track.previewUrl = trackSmp.previewUrl; - track.trackNumber = trackSmp.trackNumber; - track.type = trackSmp.type; - track.uri = trackSmp.uri; - return track; -} diff --git a/lib/helpers/timed-lyrics.dart b/lib/helpers/timed-lyrics.dart deleted file mode 100644 index 67c7e029..00000000 --- a/lib/helpers/timed-lyrics.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:html/dom.dart'; -import 'package:http/http.dart' as http; -import 'package:html/parser.dart'; -import 'package:collection/collection.dart'; -import 'package:spotube/helpers/contains-text-in-bracket.dart'; -import 'package:spotube/helpers/getLyrics.dart'; -import 'package:spotube/models/Logger.dart'; -import 'package:spotube/models/SpotubeTrack.dart'; - -final logger = getLogger("getTimedLyrics"); - -class SubtitleSimple { - Uri uri; - String name; - List lyrics; - int rating; - SubtitleSimple({ - required this.uri, - required this.name, - required this.lyrics, - required this.rating, - }); -} - -class LyricSlice { - Duration time; - String text; - - LyricSlice({required this.time, required this.text}); - - @override - String toString() { - return "LyricsSlice({time: $time, text: $text})"; - } -} - -const baseUri = "https://www.rentanadviser.com/subtitles"; - -Future getTimedLyrics(SpotubeTrack track) async { - final artistNames = - track.artists?.map((artist) => artist.name!).toList() ?? []; - final query = getTitle( - track.name!, - artists: artistNames, - ); - - logger.v("[Searching Subtitle] $query"); - - final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace( - queryParameters: {"q": query}, - ); - - final res = await http.get(searchUri); - final document = parse(res.body); - final results = - document.querySelectorAll("#tablecontainer table tbody tr td a"); - - final rateSortedResults = results.map((result) { - final title = result.text.trim().toLowerCase(); - int points = 0; - final hasAllArtists = track.artists - ?.map((artist) => artist.name!) - .every((artist) => title.contains(artist.toLowerCase())) ?? - false; - final hasTrackName = title.contains(track.name!.toLowerCase()); - final isNotLive = !containsTextInBracket(title, "live"); - final exactYtMatch = title == track.ytTrack.title.toLowerCase(); - if (exactYtMatch) points = 7; - for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) { - if (criteria) points++; - } - return {"result": result, "points": points}; - }).sorted((a, b) => (b["points"] as int).compareTo(a["points"] as int)); - - // not result was found at all - if (rateSortedResults.first["points"] == 0) { - logger.e("[Subtitle not found] ${track.name}"); - return Future.error("Subtitle lookup failed", StackTrace.current); - } - - final topResult = rateSortedResults.first["result"] as Element; - final subtitleUri = - Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc"); - - logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri"); - - final lrcDocument = parse((await http.get(subtitleUri)).body); - final lrcList = lrcDocument - .querySelector("#ctl00_ContentPlaceHolder1_lbllyrics") - ?.innerHtml - .replaceAll(RegExp(r'

.*

'), "") - .split("
") - .map((e) { - e = e.trim(); - final regexp = RegExp(r'\[.*\]'); - final timeStr = regexp - .firstMatch(e) - ?.group(0) - ?.replaceAll(RegExp(r'\[|\]'), "") - .trim() - .split(":"); - final minuteSeconds = timeStr?.last.split("."); - - return LyricSlice( - time: Duration( - minutes: int.parse(timeStr?.first ?? "0"), - seconds: int.parse(minuteSeconds?.first ?? "0"), - milliseconds: int.parse(minuteSeconds?.last ?? "0"), - ), - text: e.split(regexp).last); - }).toList() ?? - []; - - final subtitle = SubtitleSimple( - name: topResult.text.trim(), - uri: subtitleUri, - lyrics: lrcList, - rating: rateSortedResults.first["points"] as int, - ); - - return subtitle; -} diff --git a/lib/helpers/zero-pad-num-str.dart b/lib/helpers/zero-pad-num-str.dart deleted file mode 100644 index f6c0ce10..00000000 --- a/lib/helpers/zero-pad-num-str.dart +++ /dev/null @@ -1,3 +0,0 @@ -String zeroPadNumStr(int input) { - return input < 10 ? "0$input" : input.toString(); -} diff --git a/lib/main.dart b/lib/main.dart index ffc8f322..287a7a95 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ void main() async { await Hive.initFlutter(); Hive.registerAdapter(CacheTrackAdapter()); Hive.registerAdapter(CacheTrackEngagementAdapter()); + Hive.registerAdapter(CacheTrackSkipSegmentAdapter()); if (kIsDesktop) { WidgetsFlutterBinding.ensureInitialized(); doWhenWindowReady(() async { @@ -46,7 +47,7 @@ void main() async { } MobileAudioService? audioServiceHandler; runApp(ProviderScope( - child: Spotube(), + child: const Spotube(), overrides: [ playbackProvider.overrideWithProvider(ChangeNotifierProvider( (ref) { diff --git a/lib/models/LyricsModels.dart b/lib/models/LyricsModels.dart new file mode 100644 index 00000000..118bd8c3 --- /dev/null +++ b/lib/models/LyricsModels.dart @@ -0,0 +1,24 @@ +class SubtitleSimple { + Uri uri; + String name; + List lyrics; + int rating; + SubtitleSimple({ + required this.uri, + required this.name, + required this.lyrics, + required this.rating, + }); +} + +class LyricSlice { + Duration time; + String text; + + LyricSlice({required this.time, required this.text}); + + @override + String toString() { + return "LyricsSlice({time: $time, text: $text})"; + } +} diff --git a/lib/models/SpotubeTrack.dart b/lib/models/SpotubeTrack.dart index bad8a51b..fcdabcad 100644 --- a/lib/models/SpotubeTrack.dart +++ b/lib/models/SpotubeTrack.dart @@ -15,16 +15,19 @@ enum SpotubeTrackMatchAlgorithm { class SpotubeTrack extends Track { Video ytTrack; String ytUri; + List> skipSegments; SpotubeTrack( this.ytTrack, this.ytUri, + this.skipSegments, ) : super(); SpotubeTrack.fromTrack({ required Track track, required this.ytTrack, required this.ytUri, + required this.skipSegments, }) : super() { album = track.album; artists = track.artists; @@ -51,6 +54,8 @@ class SpotubeTrack extends Track { track: Track.fromJson(map), ytTrack: VideoToJson.fromJson(map["ytTrack"]), ytUri: map["ytUri"], + skipSegments: + List.castFrom>(map["skipSegments"]), ); } @@ -74,6 +79,7 @@ class SpotubeTrack extends Track { "uri": uri, "ytTrack": ytTrack.toJson(), "ytUri": ytUri, + "skipSegments": skipSegments }; } } diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index e6431933..afa7ca53 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -9,11 +9,6 @@ import 'package:hive/hive.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/extensions/yt-video-from-cache-track.dart'; -import 'package:spotube/helpers/artist-to-string.dart'; -import 'package:spotube/helpers/contains-text-in-bracket.dart'; -import 'package:spotube/helpers/getLyrics.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/SpotubeTrack.dart'; @@ -23,9 +18,13 @@ import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/services/LinuxAudioService.dart'; import 'package:spotube/services/MobileAudioService.dart'; import 'package:spotube/utils/PersistedChangeNotifier.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist; import 'package:collection/collection.dart'; import 'package:spotube/extensions/list-sort-multiple.dart'; +import 'package:http/http.dart' as http; enum PlaybackStatus { playing, @@ -33,6 +32,11 @@ enum PlaybackStatus { idle, } +enum AudioQuality { + high, + low, +} + class Playback extends PersistedChangeNotifier { // player properties bool isShuffled; @@ -114,14 +118,14 @@ class Playback extends PersistedChangeNotifier { } final currentTrackIndex = - playlist!.tracks.indexWhere((t) => t.id == track?.id); + playlist?.tracks.indexWhere((t) => t.id == track?.id); // when the track progress is above 80%, track isn't the last // and is not already fetch and nothing is fetching currently if (pos.inSeconds > currentDuration.inSeconds * .8 && playlist != null && currentTrackIndex != playlist!.tracks.length - 1 && - playlist!.tracks.elementAt(currentTrackIndex + 1) + playlist!.tracks.elementAt(currentTrackIndex! + 1) is! SpotubeTrack && !_isPreSearching) { _isPreSearching = true; @@ -132,6 +136,15 @@ class Playback extends PersistedChangeNotifier { return v; }); } + if (track != null && preferences.skipSponsorSegments) { + for (final segment in track!.skipSegments) { + if (pos.inSeconds == segment["start"] || + (pos.inSeconds > segment["start"]! && + pos.inSeconds < segment["end"]!)) { + seekPosition(Duration(seconds: segment["end"]!)); + } + } + } }), ]); }()); @@ -186,8 +199,10 @@ class Playback extends PersistedChangeNotifier { id: track.id!, title: track.name!, album: track.album?.name, - artist: artistsToString(track.artists ?? []), - artUri: Uri.parse(imageToUrlString(track.album?.images)), + artist: TypeConversionUtils.artists_X_String( + track.artists ?? []), + artUri: Uri.parse( + TypeConversionUtils.image_X_UrlString(track.album?.images)), duration: track.ytTrack.duration, ); mobileAudioService?.addItem(tag); @@ -274,130 +289,192 @@ class Playback extends PersistedChangeNotifier { ); } + Future>> getSkipSegments(String id) async { + if (!preferences.skipSponsorSegments) return []; + try { + final res = await http.get(Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + )); + + final data = jsonDecode(res.body); + final segments = data.map((obj) { + return Map.castFrom({ + "start": obj["segment"].first.toInt(), + "end": obj["segment"].last.toInt(), + }); + }).toList(); + _logger.v( + "[SponsorBlock] successfully fetched skip segments for ${track?.name} | ${track?.ytTrack.id.value}", + ); + return List.castFrom>(segments); + } catch (e, stack) { + _logger.e("[getSkipSegments]", e, stack); + return List.castFrom>([]); + } + } + // playlist & track list methods Future toSpotubeTrack(Track track) async { - final format = preferences.ytSearchFormat; - final matchAlgorithm = preferences.trackMatchAlgorithm; - final artistsName = - track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ?? - []; - final audioQuality = preferences.audioQuality; - _logger.v("[Track Search Artists] $artistsName"); - final mainArtist = artistsName.first; - final featuredArtists = artistsName.length > 1 - ? "feat. " + artistsName.sublist(1).join(" ") - : ""; - final title = getTitle( - track.name!, - artists: artistsName, - onlyCleanArtist: true, - ).trim(); - _logger.v("[Track Search Title] $title"); - final queryString = format - .replaceAll("\$MAIN_ARTIST", mainArtist) - .replaceAll("\$TITLE", title) - .replaceAll("\$FEATURED_ARTISTS", featuredArtists); - _logger.v("[Youtube Search Term] $queryString"); + try { + final format = preferences.ytSearchFormat; + final matchAlgorithm = preferences.trackMatchAlgorithm; + final artistsName = track.artists + ?.map((ar) => ar.name) + .toList() + .whereNotNull() + .toList() ?? + []; + final audioQuality = preferences.audioQuality; + _logger.v("[Track Search Artists] $artistsName"); + final mainArtist = artistsName.first; + final featuredArtists = artistsName.length > 1 + ? "feat. " + artistsName.sublist(1).join(" ") + : ""; + final title = ServiceUtils.getTitle( + track.name!, + artists: artistsName, + onlyCleanArtist: true, + ).trim(); + _logger.v("[Track Search Title] $title"); + final queryString = format + .replaceAll("\$MAIN_ARTIST", mainArtist) + .replaceAll("\$TITLE", title) + .replaceAll("\$FEATURED_ARTISTS", featuredArtists); + _logger.v("[Youtube Search Term] $queryString"); - Video ytVideo; - final cachedTrack = await cache.get(track.id); - if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) { - _logger.v( - "[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}", + Video ytVideo; + final cachedTrack = await cache.get(track.id); + if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) { + _logger.v( + "[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}", + ); + ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack); + } else { + VideoSearchList videos = + await raceMultiple(() => youtube.search.search(queryString)); + if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { + List ratedRankedVideos = videos + .map((video) { + // the find should be lazy thus everything case insensitive + final ytTitle = video.title.toLowerCase(); + final bool hasTitle = ytTitle.contains(title); + final bool hasAllArtists = track.artists?.every( + (artist) => ytTitle.contains(artist.name!.toLowerCase()), + ) ?? + false; + final bool authorIsArtist = + track.artists?.first.name?.toLowerCase() == + video.author.toLowerCase(); + + final bool hasNoLiveInTitle = + !PrimitiveUtils.containsTextInBracket(ytTitle, "live"); + + int rate = 0; + for (final el in [ + hasTitle, + hasAllArtists, + if (matchAlgorithm == + SpotubeTrackMatchAlgorithm.authenticPopular) + authorIsArtist, + hasNoLiveInTitle, + !video.isLive, + ]) { + if (el) rate++; + } + // can't let pass any non title matching track + if (!hasTitle) rate = rate - 2; + return { + "video": video, + "points": rate, + "views": video.engagement.viewCount, + }; + }) + .toList() + .sortByProperties( + [false, false], + ["points", "views"], + ); + + ytVideo = ratedRankedVideos.first["video"] as Video; + } else { + ytVideo = videos.where((video) => !video.isLive).first; + } + } + + StreamManifest trackManifest = await raceMultiple( + () => youtube.videos.streams.getManifest(ytVideo.id), ); - ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack); - } else { - VideoSearchList videos = - await raceMultiple(() => youtube.search.search(queryString)); - if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { - List ratedRankedVideos = videos - .map((video) { - // the find should be lazy thus everything case insensitive - final ytTitle = video.title.toLowerCase(); - final bool hasTitle = ytTitle.contains(title); - final bool hasAllArtists = track.artists?.every( - (artist) => ytTitle.contains(artist.name!.toLowerCase()), - ) ?? - false; - final bool authorIsArtist = - track.artists?.first.name?.toLowerCase() == - video.author.toLowerCase(); - final bool hasNoLiveInTitle = - !containsTextInBracket(ytTitle, "live"); + _logger.v( + "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", + ); - int rate = 0; - for (final el in [ - hasTitle, - hasAllArtists, - if (matchAlgorithm == - SpotubeTrackMatchAlgorithm.authenticPopular) - authorIsArtist, - hasNoLiveInTitle, - !video.isLive, - ]) { - if (el) rate++; - } - // can't let pass any non title matching track - if (!hasTitle) rate = rate - 2; - return { - "video": video, - "points": rate, - "views": video.engagement.viewCount, - }; - }) - .toList() - .sortByProperties( - [false, false], - ["points", "views"], - ); + final audioManifest = trackManifest.audioOnly.where((info) { + final isMp4a = info.codec.mimeType == "audio/mp4"; + if (Platform.isLinux) { + return !isMp4a; + } else if (Platform.isMacOS || Platform.isIOS) { + return isMp4a; + } else { + return true; + } + }); - ytVideo = ratedRankedVideos.first["video"] as Video; - } else { - ytVideo = videos.where((video) => !video.isLive).first; + final ytUri = (audioQuality == AudioQuality.high + ? audioManifest.withHighestBitrate() + : audioManifest.sortByBitrate().last) + .url + .toString(); + + final skipSegments = cachedTrack?.skipSegments != null && + cachedTrack!.skipSegments!.isNotEmpty + ? cachedTrack.skipSegments! + .map( + (segment) => segment.toJson(), + ) + .toList() + : await getSkipSegments(ytVideo.id.value); + + // only save when the track isn't available in the cache with same + // matchAlgorithm + if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) { + await cache.put( + track.id!, + CacheTrack.fromVideo( + ytVideo, + matchAlgorithm.name, + skipSegments: skipSegments, + ), + ); } + + return SpotubeTrack.fromTrack( + track: track, + ytTrack: ytVideo, + // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia + // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' + // codec/mimetype for those Platforms + ytUri: ytUri, + skipSegments: skipSegments, + ); + } catch (e, stack) { + _logger.e("topSpotubeTrack", e, stack); + rethrow; } - - StreamManifest trackManifest = await raceMultiple( - () => youtube.videos.streams.getManifest(ytVideo.id), - ); - - _logger.v( - "[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}", - ); - - final audioManifest = trackManifest.audioOnly.where((info) { - final isMp4a = info.codec.mimeType == "audio/mp4"; - if (Platform.isLinux) { - return !isMp4a; - } else if (Platform.isMacOS || Platform.isIOS) { - return isMp4a; - } else { - return true; - } - }); - - final ytUri = (audioQuality == AudioQuality.high - ? audioManifest.withHighestBitrate() - : audioManifest.sortByBitrate().last) - .url - .toString(); - - // only save when the track isn't available in the cache with same - // matchAlgorithm - if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) { - await cache.put( - track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name)); - } - - return SpotubeTrack.fromTrack( - track: track, - ytTrack: ytVideo, - // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia - // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' - // codec/mimetype for those Platforms - ytUri: ytUri, - ); } Future setPlaylistPosition(int position) async { @@ -429,13 +506,24 @@ class Playback extends PersistedChangeNotifier { @override FutureOr loadFromLocal(Map map) async { - if (map["playlist"] != null) { - playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"])); + try { + if (map["playlist"] != null) { + playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"])); + } + if (map["track"] != null) { + final Map trackMap = jsonDecode(map["track"]); + // for backwards compatibility + if (!trackMap.containsKey("skipSegments")) { + trackMap["skipSegments"] = await getSkipSegments( + trackMap["id"], + ); + } + track = SpotubeTrack.fromJson(trackMap); + } + volume = map["volume"] ?? volume; + } catch (e) { + _logger.e("loadFromLocal", e); } - if (map["track"] != null) { - track = SpotubeTrack.fromJson(jsonDecode(map["track"])); - } - volume = map["volume"] ?? volume; } @override diff --git a/lib/provider/SpotifyDI.dart b/lib/provider/SpotifyDI.dart index db07a9fa..9e2433d0 100644 --- a/lib/provider/SpotifyDI.dart +++ b/lib/provider/SpotifyDI.dart @@ -1,13 +1,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Home/Home.dart'; -import 'package:spotube/helpers/get-random-element.dart'; import 'package:spotube/models/generated_secrets.dart'; import 'package:spotube/provider/Auth.dart'; +import 'package:spotube/utils/primitive_utils.dart'; final spotifyProvider = Provider((ref) { Auth authState = ref.watch(authProvider); - final anonCred = getRandomElement(spotifySecrets); + final anonCred = PrimitiveUtils.getRandomElement(spotifySecrets); SpotifyApiCredentials apiCredentials = authState.isAnonymous ? SpotifyApiCredentials( anonCred["clientId"], diff --git a/lib/provider/SpotifyRequests.dart b/lib/provider/SpotifyRequests.dart index aabe6d2e..84a1efb3 100644 --- a/lib/provider/SpotifyRequests.dart +++ b/lib/provider/SpotifyRequests.dart @@ -1,15 +1,14 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/helpers/getLyrics.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; -import 'package:spotube/helpers/timed-lyrics.dart'; -import 'package:spotube/models/SpotubeTrack.dart'; +import 'package:spotube/models/LyricsModels.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:collection/collection.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; final categoriesQuery = FutureProvider.family, int>( (ref, pageKey) { @@ -134,7 +133,7 @@ final currentUserQuery = FutureProvider( Image() ..height = 50 ..width = 50 - ..url = imageToUrlString(me.images), + ..url = TypeConversionUtils.image_X_UrlString(me.images), ]; } return me; @@ -172,7 +171,7 @@ final geniusLyricsQuery = FutureProvider( if (currentTrack == null) { return "“Give this player a track to play”\n- S'Challa"; } - return getLyrics( + return ServiceUtils.getLyrics( currentTrack.name!, currentTrack.artists?.map((s) => s.name).whereNotNull().toList() ?? [], apiKey: geniusAccessToken, @@ -185,6 +184,6 @@ final rentanadviserLyricsQuery = FutureProvider( (ref) { final currentTrack = ref.watch(playbackProvider.select((s) => s.track)); if (currentTrack == null) return null; - return getTimedLyrics(currentTrack as SpotubeTrack); + return ServiceUtils.getTimedLyrics(currentTrack); }, ); diff --git a/lib/provider/UserPreferences.dart b/lib/provider/UserPreferences.dart index be069305..a51ddf63 100644 --- a/lib/provider/UserPreferences.dart +++ b/lib/provider/UserPreferences.dart @@ -3,12 +3,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; -import 'package:spotube/helpers/get-random-element.dart'; -import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/models/generated_secrets.dart'; +import 'package:spotube/provider/Playback.dart'; import 'package:spotube/utils/PersistedChangeNotifier.dart'; import 'package:collection/collection.dart'; +import 'package:spotube/utils/primitive_utils.dart'; class UserPreferences extends PersistedChangeNotifier { ThemeMode themeMode; @@ -22,6 +22,7 @@ class UserPreferences extends PersistedChangeNotifier { MaterialColor accentColorScheme; MaterialColor backgroundColorScheme; + bool skipSponsorSegments; UserPreferences({ required this.geniusAccessToken, required this.recommendationMarket, @@ -33,6 +34,7 @@ class UserPreferences extends PersistedChangeNotifier { this.checkUpdate = true, this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular, this.audioQuality = AudioQuality.high, + this.skipSponsorSegments = true, }) : super(); void setThemeMode(ThemeMode mode) { @@ -95,13 +97,19 @@ class UserPreferences extends PersistedChangeNotifier { updatePersistence(); } + void setSkipSponsorSegments(bool should) { + skipSponsorSegments = should; + notifyListeners(); + updatePersistence(); + } + @override FutureOr loadFromLocal(Map map) { saveTrackLyrics = map["saveTrackLyrics"] ?? false; recommendationMarket = map["recommendationMarket"] ?? recommendationMarket; checkUpdate = map["checkUpdate"] ?? checkUpdate; - geniusAccessToken = - map["geniusAccessToken"] ?? getRandomElement(lyricsSecrets); + geniusAccessToken = map["geniusAccessToken"] ?? + PrimitiveUtils.getRandomElement(lyricsSecrets); ytSearchFormat = map["ytSearchFormat"] ?? ytSearchFormat; themeMode = ThemeMode.values[map["themeMode"] ?? 0]; @@ -117,6 +125,7 @@ class UserPreferences extends PersistedChangeNotifier { audioQuality = map["audioQuality"] != null ? AudioQuality.values[map["audioQuality"]] : audioQuality; + skipSponsorSegments = map["skipSponsorSegments"] ?? skipSponsorSegments; } @override @@ -132,6 +141,7 @@ class UserPreferences extends PersistedChangeNotifier { "checkUpdate": checkUpdate, "trackMatchAlgorithm": trackMatchAlgorithm.index, "audioQuality": audioQuality.index, + "skipSponsorSegments": skipSponsorSegments, }; } } diff --git a/lib/services/LinuxAudioService.dart b/lib/services/LinuxAudioService.dart index ea620fef..29d56be9 100644 --- a/lib/services/LinuxAudioService.dart +++ b/lib/services/LinuxAudioService.dart @@ -4,9 +4,9 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:dbus/dbus.dart'; import 'package:spotube/provider/DBus.dart'; -import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class _MprisMediaPlayer2 extends DBusObject { /// Creates a new object to expose on [path]. @@ -296,8 +296,10 @@ class _MprisMediaPlayer2Player extends DBusObject { DBusDict.stringVariant({ "mpris:trackid": DBusString("${path.value}/Track/$id"), "mpris:length": DBusInt32(playback.currentDuration.inMicroseconds), - "mpris:artUrl": - DBusString(imageToUrlString(playback.track?.album?.images)), + "mpris:artUrl": DBusString( + TypeConversionUtils.image_X_UrlString( + playback.track?.album?.images), + ), "xesam:album": DBusString(playback.track!.album!.name!), "xesam:artist": DBusArray.string( playback.track!.artists!.map((artist) => artist.name!), diff --git a/lib/utils/primitive_utils.dart b/lib/utils/primitive_utils.dart new file mode 100644 index 00000000..066da341 --- /dev/null +++ b/lib/utils/primitive_utils.dart @@ -0,0 +1,37 @@ +import 'dart:math'; +import 'package:uuid/uuid.dart'; + +abstract class PrimitiveUtils { + static bool containsTextInBracket(String matcher, String text) { + final allMatches = RegExp(r"(?<=\().+?(?=\))").allMatches(matcher); + if (allMatches.isEmpty) return false; + return allMatches + .map((e) => e.group(0)) + .every((match) => match?.contains(text) ?? false); + } + + static final Random _random = Random(); + static T getRandomElement(List list) { + return list[_random.nextInt(list.length)]; + } + + static const uuid = Uuid(); + + static String toReadableNumber(double num) { + if (num > 999 && num < 99999) { + return "${(num / 1000).toStringAsFixed(0)}K"; + } else if (num > 99999 && num < 999999) { + return "${(num / 1000).toStringAsFixed(0)}K"; + } else if (num > 999999 && num < 999999999) { + return "${(num / 1000000).toStringAsFixed(0)}M"; + } else if (num > 999999999) { + return "${(num / 1000000000).toStringAsFixed(0)}B"; + } else { + return num.toString(); + } + } + + static String zeroPadNumStr(int input) { + return input < 10 ? "0$input" : input.toString(); + } +} diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart new file mode 100644 index 00000000..315c24ee --- /dev/null +++ b/lib/utils/service_utils.dart @@ -0,0 +1,359 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:html/dom.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Home/Home.dart'; +import 'package:spotube/models/LocalStorageKeys.dart'; +import 'package:spotube/models/Logger.dart'; +import 'package:http/http.dart' as http; +import 'package:spotube/models/LyricsModels.dart'; +import 'package:spotube/models/SpotubeTrack.dart'; +import 'package:spotube/models/generated_secrets.dart'; +import 'package:spotube/provider/Auth.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:collection/collection.dart'; +import 'package:html/parser.dart' as parser; +import 'package:url_launcher/url_launcher.dart'; + +abstract class ServiceUtils { + static final logger = getLogger("ServiceUtils"); + + static String clearArtistsOfTitle(String title, List artists) { + return title + .replaceAll(RegExp(artists.join("|"), caseSensitive: false), "") + .trim(); + } + + static String getTitle( + String title, { + List artists = const [], + bool onlyCleanArtist = false, + }) { + final match = RegExp(r"(?<=\().+?(?=\))").firstMatch(title)?.group(0); + final artistInBracket = + artists.any((artist) => match?.contains(artist) ?? false); + + if (artistInBracket) { + title = title.replaceAll( + RegExp(" *\\([^)]*\\) *"), + '', + ); + } + + title = clearArtistsOfTitle(title, artists); + if (onlyCleanArtist) { + artists = []; + } + + return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}" + .toLowerCase() + .replaceAll(RegExp(" *\\[[^\\]]*]"), '') + .replaceAll(RegExp("feat.|ft."), '') + .replaceAll(RegExp("\\s+"), ' ') + .trim(); + } + + static Future extractLyrics(Uri url) async { + try { + var response = await http.get(url); + + Document document = parser.parse(response.body); + var lyrics = document.querySelector('div.lyrics')?.text.trim(); + if (lyrics == null) { + lyrics = ""; + document + .querySelectorAll("div[class^=\"Lyrics__Container\"]") + .forEach((element) { + if (element.text.trim().isNotEmpty) { + var snippet = element.innerHtml.replaceAll("
", "\n").replaceAll( + RegExp("<(?!\\s*br\\s*\\/?)[^>]+>", caseSensitive: false), + "", + ); + var el = document.createElement("textarea"); + el.innerHtml = snippet; + lyrics = "$lyrics${el.text.trim()}\n\n"; + } + }); + } + + return lyrics; + } catch (e, stack) { + logger.e("extractLyrics", e, stack); + rethrow; + } + } + + static Future searchSong( + String title, + List artist, { + String? apiKey, + bool optimizeQuery = false, + bool authHeader = false, + }) async { + try { + if (apiKey == "" || apiKey == null) { + apiKey = PrimitiveUtils.getRandomElement(lyricsSecrets); + } + const searchUrl = 'https://api.genius.com/search?q='; + String song = + optimizeQuery ? getTitle(title, artists: artist) : "$title $artist"; + + String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; + Map headers = {"Authorization": 'Bearer $apiKey'}; + final response = await http.get( + Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), + headers: authHeader ? headers : null, + ); + Map data = jsonDecode(response.body)["response"]; + if (data["hits"]?.length == 0) return null; + List results = data["hits"]?.map((val) { + return { + "id": val["result"]["id"], + "full_title": val["result"]["full_title"], + "albumArt": val["result"]["song_art_image_url"], + "url": val["result"]["url"], + "author": val["result"]["primary_artist"]["name"], + }; + }).toList(); + return results; + } catch (e, stack) { + logger.e("searchSong", e, stack); + rethrow; + } + } + + static Future getLyrics( + String title, + List artists, { + required String apiKey, + bool optimizeQuery = false, + bool authHeader = false, + }) async { + try { + final results = await searchSong( + title, + artists, + apiKey: apiKey, + optimizeQuery: optimizeQuery, + authHeader: authHeader, + ); + if (results == null) return null; + title = getTitle( + title, + artists: artists, + onlyCleanArtist: true, + ).trim(); + final ratedLyrics = results.map((result) { + final gTitle = (result["full_title"] as String).toLowerCase(); + int points = 0; + final hasTitle = gTitle.contains(title); + final hasAllArtists = + artists.every((artist) => gTitle.contains(artist.toLowerCase())); + final String lyricAuthor = result["author"].toLowerCase(); + final fromOriginalAuthor = + lyricAuthor.contains(artists.first.toLowerCase()); + + for (final criteria in [ + hasTitle, + hasAllArtists, + fromOriginalAuthor, + ]) { + if (criteria) points++; + } + return {"result": result, "points": points}; + }).sorted( + (a, b) => ((a["points"] as int).compareTo(a["points"] as int)), + ); + final worthyOne = ratedLyrics.first["result"]; + + String? lyrics = await extractLyrics(Uri.parse(worthyOne["url"])); + return lyrics; + } catch (e, stack) { + logger.e("getLyrics", e, stack); + return null; + } + } + + static Future connectIpc(String authUri, String redirectUri) async { + try { + logger.i("[connectIpc][Launching]: $authUri"); + await launchUrl( + Uri.parse(authUri), + mode: LaunchMode.externalApplication, + ); + + HttpServer server = + await HttpServer.bind(InternetAddress.loopbackIPv4, 4304); + logger.i("[connectIpc] Server started"); + + await for (HttpRequest request in server) { + if (request.uri.path == "/auth/spotify/callback" && + request.method == "GET") { + String? code = request.uri.queryParameters["code"]; + if (code != null) { + request.response + ..statusCode = HttpStatus.ok + ..write("Authentication successful. Now Go back to Spotube") + ..close(); + return "$redirectUri?code=$code"; + } else { + request.response + ..statusCode = HttpStatus.forbidden + ..write("Authorization failed start over!") + ..close(); + throw Exception("No code provided"); + } + } + } + } catch (e, stack) { + logger.e("connectIpc", e, stack); + rethrow; + } + return null; + } + + static const authRedirectUri = "http://localhost:4304/auth/spotify/callback"; + + static Future oauthLogin(Auth auth, + {required String clientId, required String clientSecret}) async { + try { + String? accessToken; + String? refreshToken; + DateTime? expiration; + final credentials = SpotifyApiCredentials(clientId, clientSecret); + final grant = SpotifyApi.authorizationCodeGrant(credentials); + + final authUri = grant.getAuthorizationUrl(Uri.parse(authRedirectUri), + scopes: spotifyScopes); + + final responseUri = await connectIpc(authUri.toString(), authRedirectUri); + SharedPreferences localStorage = await SharedPreferences.getInstance(); + if (responseUri != null) { + final SpotifyApi spotify = + SpotifyApi.fromAuthCodeGrant(grant, responseUri); + final credentials = await spotify.getCredentials(); + if (credentials.accessToken != null) { + accessToken = credentials.accessToken; + await localStorage.setString( + LocalStorageKeys.accessToken, credentials.accessToken!); + } + if (credentials.refreshToken != null) { + refreshToken = credentials.refreshToken; + await localStorage.setString( + LocalStorageKeys.refreshToken, credentials.refreshToken!); + } + if (credentials.expiration != null) { + expiration = credentials.expiration; + await localStorage.setString(LocalStorageKeys.expiration, + credentials.expiration?.toString() ?? ""); + } + } + + await localStorage.setString(LocalStorageKeys.clientId, clientId); + await localStorage.setString( + LocalStorageKeys.clientSecret, + clientSecret, + ); + + auth.setAuthState( + clientId: clientId, + clientSecret: clientSecret, + accessToken: accessToken, + refreshToken: refreshToken, + expiration: expiration, + ); + } catch (e, stack) { + logger.e("oauthLogin", e, stack); + rethrow; + } + } + + static const baseUri = "https://www.rentanadviser.com/subtitles"; + + static Future getTimedLyrics(SpotubeTrack track) async { + final artistNames = + track.artists?.map((artist) => artist.name!).toList() ?? []; + final query = getTitle( + track.name!, + artists: artistNames, + ); + + logger.v("[Searching Subtitle] $query"); + + final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace( + queryParameters: {"q": query}, + ); + + final res = await http.get(searchUri); + final document = parser.parse(res.body); + final results = + document.querySelectorAll("#tablecontainer table tbody tr td a"); + + final rateSortedResults = results.map((result) { + final title = result.text.trim().toLowerCase(); + int points = 0; + final hasAllArtists = track.artists + ?.map((artist) => artist.name!) + .every((artist) => title.contains(artist.toLowerCase())) ?? + false; + final hasTrackName = title.contains(track.name!.toLowerCase()); + final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live"); + final exactYtMatch = title == track.ytTrack.title.toLowerCase(); + if (exactYtMatch) points = 7; + for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) { + if (criteria) points++; + } + return {"result": result, "points": points}; + }).sorted((a, b) => (b["points"] as int).compareTo(a["points"] as int)); + + // not result was found at all + if (rateSortedResults.first["points"] == 0) { + logger.e("[Subtitle not found] ${track.name}"); + return Future.error("Subtitle lookup failed", StackTrace.current); + } + + final topResult = rateSortedResults.first["result"] as Element; + final subtitleUri = + Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc"); + + logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri"); + + final lrcDocument = parser.parse((await http.get(subtitleUri)).body); + final lrcList = lrcDocument + .querySelector("#ctl00_ContentPlaceHolder1_lbllyrics") + ?.innerHtml + .replaceAll(RegExp(r'

.*

'), "") + .split("
") + .map((e) { + e = e.trim(); + final regexp = RegExp(r'\[.*\]'); + final timeStr = regexp + .firstMatch(e) + ?.group(0) + ?.replaceAll(RegExp(r'\[|\]'), "") + .trim() + .split(":"); + final minuteSeconds = timeStr?.last.split("."); + + return LyricSlice( + time: Duration( + minutes: int.parse(timeStr?.first ?? "0"), + seconds: int.parse(minuteSeconds?.first ?? "0"), + milliseconds: int.parse(minuteSeconds?.last ?? "0"), + ), + text: e.split(regexp).last); + }).toList() ?? + []; + + final subtitle = SubtitleSimple( + name: topResult.text.trim(), + uri: subtitleUri, + lyrics: lrcList, + rating: rateSortedResults.first["points"] as int, + ); + + return subtitle; + } +} diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart new file mode 100644 index 00000000..ce8181bf --- /dev/null +++ b/lib/utils/type_conversion_utils.dart @@ -0,0 +1,84 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:flutter/widgets.dart' hide Image; +import 'package:spotube/components/Shared/LinkText.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/utils/primitive_utils.dart'; + +abstract class TypeConversionUtils { + static String image_X_UrlString(List? images, {int index = 0}) { + return images != null && images.isNotEmpty + ? images[0].url! + : "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png"; + } + + static String artists_X_String(List artists) { + return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); + } + + static Widget artists_X_ClickableArtists( + List artists, { + WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, + WrapAlignment mainAxisAlignment = WrapAlignment.center, + TextStyle textStyle = const TextStyle(), + }) { + return Wrap( + crossAxisAlignment: crossAxisAlignment, + alignment: mainAxisAlignment, + children: artists + .asMap() + .entries + .map( + (artist) => LinkText( + (artist.key != artists.length - 1) + ? "${artist.value.name}, " + : artist.value.name!, + "/artist/${artist.value.id}", + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ) + .toList(), + ); + } + + static Album simpleAlbum_X_Album(AlbumSimple albumSimple) { + Album album = Album(); + album.albumType = albumSimple.albumType; + album.artists = albumSimple.artists; + album.availableMarkets = albumSimple.availableMarkets; + album.externalUrls = albumSimple.externalUrls; + album.href = albumSimple.href; + album.id = albumSimple.id; + album.images = albumSimple.images; + album.name = albumSimple.name; + album.releaseDate = albumSimple.releaseDate; + album.releaseDatePrecision = albumSimple.releaseDatePrecision; + album.tracks = albumSimple.tracks; + album.type = albumSimple.type; + album.uri = albumSimple.uri; + return album; + } + + static Track simpleTrack_X_Track(TrackSimple trackSmp, AlbumSimple album) { + Track track = Track(); + track.name = trackSmp.name; + track.album = album; + track.artists = trackSmp.artists; + track.availableMarkets = trackSmp.availableMarkets; + track.discNumber = trackSmp.discNumber; + track.durationMs = trackSmp.durationMs; + track.explicit = trackSmp.explicit; + track.externalUrls = trackSmp.externalUrls; + track.href = trackSmp.href; + track.id = trackSmp.id; + track.isPlayable = trackSmp.isPlayable; + track.linkedFrom = trackSmp.linkedFrom; + track.name = trackSmp.name; + track.previewUrl = trackSmp.previewUrl; + track.trackNumber = trackSmp.trackNumber; + track.type = trackSmp.type; + track.uri = trackSmp.uri; + return track; + } +}