mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
SponsorBlock API support added (cached works too)
Grouped `helpers` folder snippets into appropriate sections
This commit is contained in:
parent
68b440920c
commit
74ee2aff33
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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.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);
|
||||
(track.value.album?.images?.length ?? 1) -
|
||||
1);
|
||||
return TrackTile(
|
||||
playback,
|
||||
duration: duration,
|
||||
|
@ -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(
|
||||
|
@ -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(),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 ?? [],
|
||||
)
|
||||
],
|
||||
|
@ -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(
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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() ??
|
||||
[],
|
||||
|
@ -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,
|
||||
|
@ -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)),
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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(", ");
|
||||
}
|
@ -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(),
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
final Random _random = Random();
|
||||
T getRandomElement<T>(List<T> list) {
|
||||
return list[_random.nextInt(list.length)];
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
String zeroPadNumStr(int input) {
|
||||
return input < 10 ? "0$input" : input.toString();
|
||||
}
|
@ -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) {
|
||||
|
24
lib/models/LyricsModels.dart
Normal file
24
lib/models/LyricsModels.dart
Normal 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})";
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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,12 +289,54 @@ 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 {
|
||||
try {
|
||||
final format = preferences.ytSearchFormat;
|
||||
final matchAlgorithm = preferences.trackMatchAlgorithm;
|
||||
final artistsName =
|
||||
track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ??
|
||||
final artistsName = track.artists
|
||||
?.map((ar) => ar.name)
|
||||
.toList()
|
||||
.whereNotNull()
|
||||
.toList() ??
|
||||
[];
|
||||
final audioQuality = preferences.audioQuality;
|
||||
_logger.v("[Track Search Artists] $artistsName");
|
||||
@ -287,7 +344,7 @@ class Playback extends PersistedChangeNotifier {
|
||||
final featuredArtists = artistsName.length > 1
|
||||
? "feat. " + artistsName.sublist(1).join(" ")
|
||||
: "";
|
||||
final title = getTitle(
|
||||
final title = ServiceUtils.getTitle(
|
||||
track.name!,
|
||||
artists: artistsName,
|
||||
onlyCleanArtist: true,
|
||||
@ -324,7 +381,7 @@ class Playback extends PersistedChangeNotifier {
|
||||
video.author.toLowerCase();
|
||||
|
||||
final bool hasNoLiveInTitle =
|
||||
!containsTextInBracket(ytTitle, "live");
|
||||
!PrimitiveUtils.containsTextInBracket(ytTitle, "live");
|
||||
|
||||
int rate = 0;
|
||||
for (final el in [
|
||||
@ -383,11 +440,26 @@ class Playback extends PersistedChangeNotifier {
|
||||
.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));
|
||||
track.id!,
|
||||
CacheTrack.fromVideo(
|
||||
ytVideo,
|
||||
matchAlgorithm.name,
|
||||
skipSegments: skipSegments,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SpotubeTrack.fromTrack(
|
||||
@ -397,7 +469,12 @@ class Playback extends PersistedChangeNotifier {
|
||||
// ('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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setPlaylistPosition(int position) async {
|
||||
@ -429,13 +506,24 @@ class Playback extends PersistedChangeNotifier {
|
||||
|
||||
@override
|
||||
FutureOr<void> loadFromLocal(Map<String, dynamic> map) async {
|
||||
try {
|
||||
if (map["playlist"] != null) {
|
||||
playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"]));
|
||||
}
|
||||
if (map["track"] != null) {
|
||||
track = SpotubeTrack.fromJson(jsonDecode(map["track"]));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -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"],
|
||||
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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!),
|
||||
|
37
lib/utils/primitive_utils.dart
Normal file
37
lib/utils/primitive_utils.dart
Normal 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();
|
||||
}
|
||||
}
|
359
lib/utils/service_utils.dart
Normal file
359
lib/utils/service_utils.dart
Normal 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;
|
||||
}
|
||||
}
|
84
lib/utils/type_conversion_utils.dart
Normal file
84
lib/utils/type_conversion_utils.dart
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user