SponsorBlock API support added (cached works too)

Grouped `helpers` folder snippets into appropriate sections
This commit is contained in:
Kingkor Roy Tirtho 2022-07-19 18:15:12 +06:00
parent 68b440920c
commit 74ee2aff33
48 changed files with 942 additions and 890 deletions

View File

@ -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<ArtistSimple>(album.artists ?? [])}",
"Album • ${TypeConversionUtils.artists_X_String<ArtistSimple>(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<Track> 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;

View File

@ -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,
);

View File

@ -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,

View File

@ -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(

View File

@ -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(),
),
),

View File

@ -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,

View File

@ -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<Artist>(playback.track?.artists ?? []),
TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []),
style: breakpoint >= Breakpoints.md
? textTheme.headline5
: textTheme.headline6,

View File

@ -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<Duration>(
(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<Artist>(
TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []),
style: breakpoint >= Breakpoints.md
? textTheme.headline5

View File

@ -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,
),

View File

@ -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<Duration>(
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;

View File

@ -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;

View File

@ -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 ?? [],
)
],

View File

@ -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(

View File

@ -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),
),
);
},

View File

@ -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,

View File

@ -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<String>((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(),
),
),

View File

@ -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,

View File

@ -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<File?>(null);
final downloadFolder = useState<String?>(null);
String fileName =
"${track?.name} - ${artistsToString<Artist>(track?.artists ?? [])}";
"${track?.name} - ${TypeConversionUtils.artists_X_String<Artist>(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() ??
[],

View File

@ -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<Track>
? tracks
.map((track) => simpleTrackToTrack(track, album!))
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(
track, album!))
.toList()
: tracks,
onTrackPlayButtonPressed: onPlay,

View File

@ -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)),

View File

@ -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,

View File

@ -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<String, int> toJson() {
return Map.castFrom<String, dynamic, String, int>(
{"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<CacheTrackSkipSegment>? skipSegments;
CacheTrack();
CacheTrack.fromVideo(Video video, this.mode)
: id = video.id.value,
CacheTrack.fromVideo(
Video video,
this.mode, {
required List<Map<String, int>> 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();
}

View File

@ -45,6 +45,42 @@ class CacheTrackEngagementAdapter extends TypeAdapter<CacheTrackEngagement> {
typeId == other.typeId;
}
class CacheTrackSkipSegmentAdapter extends TypeAdapter<CacheTrackSkipSegment> {
@override
final int typeId = 3;
@override
CacheTrackSkipSegment read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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<CacheTrack> {
@override
final int typeId = 1;
@ -66,13 +102,14 @@ class CacheTrackAdapter extends TypeAdapter<CacheTrack> {
..keywords = (fields[7] as List?)?.cast<String>()
..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<CacheTrackSkipSegment>();
}
@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<CacheTrack> {
..writeByte(9)
..write(obj.mode)
..writeByte(10)
..write(obj.author);
..write(obj.author)
..writeByte(11)
..write(obj.skipSegments);
}
@override

View File

@ -1,5 +0,0 @@
import 'package:spotify/spotify.dart';
String artistsToString<T extends ArtistSimple>(List<T> artists) {
return artists.map((e) => e.name?.replaceAll(",", " ")).join(", ");
}

View File

@ -1,29 +0,0 @@
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/LinkText.dart';
Widget artistsToClickableArtists(
List<ArtistSimple> 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(),
);
}

View File

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

View File

@ -1,6 +0,0 @@
import 'dart:math';
final Random _random = Random();
T getRandomElement<T>(List<T> list) {
return list[_random.nextInt(list.length)];
}

View File

@ -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<String> artists) {
return title
.replaceAll(RegExp(artists.join("|"), caseSensitive: false), "")
.trim();
}
String getTitle(
String title, {
List<String> 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<String?> 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("<br>", "\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<List?> searchSong(
String title,
List<String> 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<String, String> 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 <String, dynamic>{
"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<String?> getLyrics(
String title,
List<String> 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;
}
}

View File

@ -1,9 +0,0 @@
import 'package:spotify/spotify.dart';
import 'package:uuid/uuid.dart' show Uuid;
const uuid = Uuid();
String imageToUrlString(List<Image>? images, {int index = 0}) {
return images != null && images.isNotEmpty
? images[0].url!
: "https://avatars.dicebear.com/api/bottts/${uuid.v4()}.png";
}

View File

@ -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<void> 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;
}
}

View File

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

View File

@ -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<SpotubeTrack> toSpotubeTrack({
required YoutubeExplode youtube,
required Track track,
required String format,
required SpotubeTrackMatchAlgorithm matchAlgorithm,
required AudioQuality audioQuality,
LazyBox<CacheTrack>? 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<Map> 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,
);
}

View File

@ -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<String?> 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;
}

View File

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

View File

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

View File

@ -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<LyricSlice> 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<SubtitleSimple?> 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'<h3>.*</h3>'), "")
.split("<br>")
.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;
}

View File

@ -1,3 +0,0 @@
String zeroPadNumStr(int input) {
return input < 10 ? "0$input" : input.toString();
}

View File

@ -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) {

View File

@ -0,0 +1,24 @@
class SubtitleSimple {
Uri uri;
String name;
List<LyricSlice> 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})";
}
}

View File

@ -15,16 +15,19 @@ enum SpotubeTrackMatchAlgorithm {
class SpotubeTrack extends Track {
Video ytTrack;
String ytUri;
List<Map<String, int>> 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<dynamic, Map<String, int>>(map["skipSegments"]),
);
}
@ -74,6 +79,7 @@ class SpotubeTrack extends Track {
"uri": uri,
"ytTrack": ytTrack.toJson(),
"ytUri": ytUri,
"skipSegments": skipSegments
};
}
}

View File

@ -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 ?? <ArtistSimple>[]),
artUri: Uri.parse(imageToUrlString(track.album?.images)),
artist: TypeConversionUtils.artists_X_String(
track.artists ?? <ArtistSimple>[]),
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<List<Map<String, int>>> 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<String, dynamic, String, int>({
"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<dynamic, Map<String, int>>(segments);
} catch (e, stack) {
_logger.e("[getSkipSegments]", e, stack);
return List.castFrom<dynamic, Map<String, int>>([]);
}
}
// playlist & track list methods
Future<SpotubeTrack> 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<Map> 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<Map> 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<void> setPlaylistPosition(int position) async {
@ -429,13 +506,24 @@ class Playback extends PersistedChangeNotifier {
@override
FutureOr<void> loadFromLocal(Map<String, dynamic> 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<String, dynamic> 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

View File

@ -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<SpotifyApi>((ref) {
Auth authState = ref.watch(authProvider);
final anonCred = getRandomElement(spotifySecrets);
final anonCred = PrimitiveUtils.getRandomElement(spotifySecrets);
SpotifyApiCredentials apiCredentials = authState.isAnonymous
? SpotifyApiCredentials(
anonCred["clientId"],

View File

@ -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<Page<Category>, int>(
(ref, pageKey) {
@ -134,7 +133,7 @@ final currentUserQuery = FutureProvider<User>(
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<String?>(
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<SubtitleSimple?>(
(ref) {
final currentTrack = ref.watch(playbackProvider.select((s) => s.track));
if (currentTrack == null) return null;
return getTimedLyrics(currentTrack as SpotubeTrack);
return ServiceUtils.getTimedLyrics(currentTrack);
},
);

View File

@ -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<void> loadFromLocal(Map<String, dynamic> 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,
};
}
}

View File

@ -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!),

View File

@ -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<T>(List<T> 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();
}
}

View File

@ -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<String> artists) {
return title
.replaceAll(RegExp(artists.join("|"), caseSensitive: false), "")
.trim();
}
static String getTitle(
String title, {
List<String> 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<String?> 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("<br>", "\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<List?> searchSong(
String title,
List<String> 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<String, String> 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 <String, dynamic>{
"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<String?> getLyrics(
String title,
List<String> 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<String?> 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<void> 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<SubtitleSimple?> 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'<h3>.*</h3>'), "")
.split("<br>")
.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;
}
}

View File

@ -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<Image>? 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<T extends ArtistSimple>(List<T> artists) {
return artists.map((e) => e.name?.replaceAll(",", " ")).join(", ");
}
static Widget artists_X_ClickableArtists(
List<ArtistSimple> 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;
}
}