SponsorBlock API support added (cached works too)

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

View File

@ -3,13 +3,11 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/PlaybuttonCard.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/hooks/useBreakpointValue.dart';
import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class AlbumCard extends HookConsumerWidget { class AlbumCard extends HookConsumerWidget {
final Album album; final Album album;
@ -23,14 +21,14 @@ class AlbumCard extends HookConsumerWidget {
final int marginH = final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
return PlaybuttonCard( return PlaybuttonCard(
imageUrl: imageToUrlString(album.images), imageUrl: TypeConversionUtils.image_X_UrlString(album.images),
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
isPlaying: playback.playlist?.id == album.id, isPlaying: playback.playlist?.id == album.id,
isLoading: playback.status == PlaybackStatus.loading && isLoading: playback.status == PlaybackStatus.loading &&
playback.playlist?.id == album.id, playback.playlist?.id == album.id,
title: album.name!, title: album.name!,
description: description:
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}", "Album • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
onTap: () { onTap: () {
GoRouter.of(context).push("/album/${album.id}", extra: album); GoRouter.of(context).push("/album/${album.id}", extra: album);
}, },
@ -38,7 +36,8 @@ class AlbumCard extends HookConsumerWidget {
SpotifyApi spotify = ref.read(spotifyProvider); SpotifyApi spotify = ref.read(spotifyProvider);
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all()) List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
.map((track) => simpleTrackToTrack(track, album)) .map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(); .toList();
if (tracks.isEmpty) return; if (tracks.isEmpty) return;

View File

@ -5,8 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/HeartButton.dart';
import 'package:spotube/components/Shared/TrackCollectionView.dart'; import 'package:spotube/components/Shared/TrackCollectionView.dart';
import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/helpers/simple-track-to-track.dart';
import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
@ -27,7 +26,7 @@ class AlbumView extends HookConsumerWidget {
tracks: tracks, tracks: tracks,
id: album.id!, id: album.id!,
name: album.name!, name: album.name!,
thumbnail: imageToUrlString(album.images), thumbnail: TypeConversionUtils.image_X_UrlString(album.images),
), ),
tracks.indexWhere((s) => s.id == currentTrack?.id), tracks.indexWhere((s) => s.id == currentTrack?.id),
); );
@ -49,8 +48,9 @@ class AlbumView extends HookConsumerWidget {
final albumSavedSnapshot = final albumSavedSnapshot =
ref.watch(albumIsSavedForCurrentUserQuery(album.id!)); ref.watch(albumIsSavedForCurrentUserQuery(album.id!));
final albumArt = final albumArt = useMemoized(
useMemoized(() => imageToUrlString(album.images), [album.images]); () => TypeConversionUtils.image_X_UrlString(album.images),
[album.images]);
return TrackCollectionView( return TrackCollectionView(
id: album.id!, id: album.id!,
@ -66,7 +66,8 @@ class AlbumView extends HookConsumerWidget {
playPlaylist( playPlaylist(
playback, playback,
tracksSnapshot.asData!.value tracksSnapshot.asData!.value
.map((track) => simpleTrackToTrack(track, album)) .map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(), .toList(),
currentTrack: track, currentTrack: track,
); );

View File

@ -10,9 +10,6 @@ import 'package:spotube/components/Artist/ArtistCard.dart';
import 'package:spotube/components/LoaderShimmers/ShimmerArtistProfile.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerArtistProfile.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TrackTile.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/useBreakpointValue.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/CurrentPlaylist.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/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.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 { class ArtistProfile extends HookConsumerWidget {
final String artistId; final String artistId;
@ -80,7 +79,7 @@ class ArtistProfile extends HookConsumerWidget {
CircleAvatar( CircleAvatar(
radius: avatarWidth, radius: avatarWidth,
backgroundImage: CachedNetworkImageProvider( backgroundImage: CachedNetworkImageProvider(
imageToUrlString(data.images), TypeConversionUtils.image_X_UrlString(data.images),
), ),
), ),
Padding( Padding(
@ -106,7 +105,7 @@ class ArtistProfile extends HookConsumerWidget {
: textTheme.headline2, : textTheme.headline2,
), ),
Text( Text(
"${toReadableNumber(data.followers!.total!.toDouble())} followers", "${PrimitiveUtils.toReadableNumber(data.followers!.total!.toDouble())} followers",
style: breakpoint.isSm style: breakpoint.isSm
? textTheme.bodyText1 ? textTheme.bodyText1
: textTheme.headline5, : textTheme.headline5,
@ -193,7 +192,8 @@ class ArtistProfile extends HookConsumerWidget {
tracks: tracks, tracks: tracks,
id: data.id!, id: data.id!,
name: "${data.name!} To Tracks", name: "${data.name!} To Tracks",
thumbnail: imageToUrlString(data.images), thumbnail: TypeConversionUtils.image_X_UrlString(
data.images),
), ),
tracks.indexWhere((s) => s.id == currentTrack?.id), tracks.indexWhere((s) => s.id == currentTrack?.id),
); );
@ -230,11 +230,13 @@ class ArtistProfile extends HookConsumerWidget {
), ),
...topTracks.toList().asMap().entries.map((track) { ...topTracks.toList().asMap().entries.map((track) {
String duration = 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)}";
String? thumbnailUrl = imageToUrlString( String? thumbnailUrl =
TypeConversionUtils.image_X_UrlString(
track.value.album?.images, track.value.album?.images,
index: index:
(track.value.album?.images?.length ?? 1) - 1); (track.value.album?.images?.length ?? 1) -
1);
return TrackTile( return TrackTile(
playback, playback,
duration: duration, duration: duration,

View File

@ -4,13 +4,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter/material.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/useBreakpointValue.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/sideBarTiles.dart'; import 'package:spotube/models/sideBarTiles.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class Sidebar extends HookConsumerWidget { class Sidebar extends HookConsumerWidget {
final int selectedIndex; final int selectedIndex;
@ -115,7 +115,8 @@ class Sidebar extends HookConsumerWidget {
builder: (context) { builder: (context) {
final data = meSnapshot.asData?.value; final data = meSnapshot.asData?.value;
final avatarImg = imageToUrlString(data?.images, final avatarImg = TypeConversionUtils.image_X_UrlString(
data?.images,
index: (data?.images?.length ?? 1) - 1); index: (data?.images?.length ?? 1) - 1);
if (extended.value) { if (extended.value) {
return Padding( return Padding(

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart' hide Image;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Album/AlbumCard.dart';
import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.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/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class UserAlbums extends ConsumerWidget { class UserAlbums extends ConsumerWidget {
const UserAlbums({Key? key}) : super(key: key); const UserAlbums({Key? key}) : super(key: key);
@ -21,7 +21,8 @@ class UserAlbums extends ConsumerWidget {
runSpacing: 20, // gap between lines runSpacing: 20, // gap between lines
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: data children: data
.map((album) => AlbumCard(simpleAlbumToAlbum(album))) .map((album) =>
AlbumCard(TypeConversionUtils.simpleAlbum_X_Album(album)))
.toList(), .toList(),
), ),
), ),

View File

@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/helpers/oauth-login.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/utils/service_utils.dart';
class LoginForm extends HookConsumerWidget { class LoginForm extends HookConsumerWidget {
final void Function()? onDone; final void Function()? onDone;
@ -25,7 +24,7 @@ class LoginForm extends HookConsumerWidget {
clientSecretController.value.text == "") { clientSecretController.value.text == "") {
fieldError.value = true; fieldError.value = true;
} }
await oauthLogin( await ServiceUtils.oauthLogin(
ref.read(authProvider), ref.read(authProvider),
clientId: clientIdController.value.text, clientId: clientIdController.value.text,
clientSecret: clientSecretController.value.text, clientSecret: clientSecretController.value.text,

View File

@ -3,10 +3,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.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/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class Lyrics extends HookConsumerWidget { class Lyrics extends HookConsumerWidget {
final Color? titleBarForegroundColor; final Color? titleBarForegroundColor;
@ -36,7 +36,8 @@ class Lyrics extends HookConsumerWidget {
), ),
Center( Center(
child: Text( child: Text(
artistsToString<Artist>(playback.track?.artists ?? []), TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []),
style: breakpoint >= Breakpoints.md style: breakpoint >= Breakpoints.md
? textTheme.headline5 ? textTheme.headline5
: textTheme.headline6, : textTheme.headline6,

View File

@ -4,15 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart';
import 'package:spotube/components/Lyrics/LyricDelayAdjustDialog.dart'; import 'package:spotube/components/Lyrics/LyricDelayAdjustDialog.dart';
import 'package:spotube/components/Lyrics/Lyrics.dart'; import 'package:spotube/components/Lyrics/Lyrics.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/SpotubeMarqueeText.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/useAutoScrollController.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/hooks/useCustomStatusBarColor.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:spotube/provider/Playback.dart';
import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
final lyricDelayState = StateProvider<Duration>( final lyricDelayState = StateProvider<Duration>(
(ref) { (ref) {
@ -109,7 +107,7 @@ class SyncedLyrics extends HookConsumerWidget {
// when synced lyrics not found, fallback to GeniusLyrics // when synced lyrics not found, fallback to GeniusLyrics
String albumArt = useMemoized( String albumArt = useMemoized(
() => imageToUrlString( () => TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images, playback.track?.album?.images,
index: (playback.track?.album?.images?.length ?? 1) - 1, index: (playback.track?.album?.images?.length ?? 1) - 1,
), ),
@ -198,7 +196,7 @@ class SyncedLyrics extends HookConsumerWidget {
), ),
Center( Center(
child: Text( child: Text(
artistsToString<Artist>( TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []), playback.track?.artists ?? []),
style: breakpoint >= Breakpoints.md style: breakpoint >= Breakpoints.md
? textTheme.headline5 ? textTheme.headline5

View File

@ -4,11 +4,11 @@ import 'package:spotube/components/Player/PlayerActions.dart';
import 'package:spotube/components/Player/PlayerOverlay.dart'; import 'package:spotube/components/Player/PlayerOverlay.dart';
import 'package:spotube/components/Player/PlayerTrackDetails.dart'; import 'package:spotube/components/Player/PlayerTrackDetails.dart';
import 'package:spotube/components/Player/PlayerControls.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/hooks/useBreakpoints.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class Player extends HookConsumerWidget { class Player extends HookConsumerWidget {
Player({Key? key}) : super(key: key); Player({Key? key}) : super(key: key);
@ -21,7 +21,7 @@ class Player extends HookConsumerWidget {
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
String albumArt = useMemoized( String albumArt = useMemoized(
() => imageToUrlString( () => TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images, playback.track?.album?.images,
index: (playback.track?.album?.images?.length ?? 1) - 1, index: (playback.track?.album?.images?.length ?? 1) - 1,
), ),

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/hooks/playback.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/utils/primitive_utils.dart';
class PlayerControls extends HookConsumerWidget { class PlayerControls extends HookConsumerWidget {
final Color? iconColor; final Color? iconColor;
@ -35,15 +35,17 @@ class PlayerControls extends HookConsumerWidget {
StreamBuilder<Duration>( StreamBuilder<Duration>(
stream: playback.player.onPositionChanged, stream: playback.player.onPositionChanged,
builder: (context, snapshot) { builder: (context, snapshot) {
final totalMinutes = final totalMinutes = PrimitiveUtils.zeroPadNumStr(
zeroPadNumStr(duration.inMinutes.remainder(60)); duration.inMinutes.remainder(60));
final totalSeconds = final totalSeconds = PrimitiveUtils.zeroPadNumStr(
zeroPadNumStr(duration.inSeconds.remainder(60)); duration.inSeconds.remainder(60));
final currentMinutes = snapshot.hasData final currentMinutes = snapshot.hasData
? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60)) ? PrimitiveUtils.zeroPadNumStr(
snapshot.data!.inMinutes.remainder(60))
: "00"; : "00";
final currentSeconds = snapshot.hasData final currentSeconds = snapshot.hasData
? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60)) ? PrimitiveUtils.zeroPadNumStr(
snapshot.data!.inSeconds.remainder(60))
: "00"; : "00";
final sliderMax = duration.inSeconds; final sliderMax = duration.inSeconds;

View File

@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/components/Shared/NotFound.dart'; import 'package:spotube/components/Shared/NotFound.dart';
import 'package:spotube/components/Shared/TrackTile.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/hooks/useAutoScrollController.dart';
import 'package:spotube/provider/Playback.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 { class PlayerQueue extends HookConsumerWidget {
final bool floating; final bool floating;
@ -100,7 +100,7 @@ class PlayerQueue extends HookConsumerWidget {
itemBuilder: (context, i) { itemBuilder: (context, i) {
final track = tracks.asMap().entries.elementAt(i); final track = tracks.asMap().entries.elementAt(i);
String duration = 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( return AutoScrollTag(
key: ValueKey(i), key: ValueKey(i),
controller: controller, controller: controller,
@ -111,8 +111,9 @@ class PlayerQueue extends HookConsumerWidget {
playback, playback,
track: track, track: track,
duration: duration, duration: duration,
thumbnailUrl: thumbnailUrl: TypeConversionUtils.image_X_UrlString(
imageToUrlString(track.value.album?.images), track.value.album?.images,
),
isActive: playback.track?.id == track.value.id, isActive: playback.track?.id == track.value.id,
onTrackPlayButtonPressed: (currentTrack) async { onTrackPlayButtonPressed: (currentTrack) async {
if (playback.track?.id == track.value.id) return; if (playback.track?.id == track.value.id) return;

View File

@ -1,9 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget { class PlayerTrackDetails extends HookConsumerWidget {
final String? albumArt; final String? albumArt;
@ -61,7 +61,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
.bodyText1 .bodyText1
?.copyWith(fontWeight: FontWeight.bold, color: color), ?.copyWith(fontWeight: FontWeight.bold, color: color),
), ),
artistsToClickableArtists( TypeConversionUtils.artists_X_ClickableArtists(
playback.track?.artists ?? [], playback.track?.artists ?? [],
) )
], ],

View File

@ -10,12 +10,11 @@ import 'package:spotube/components/Player/PlayerActions.dart';
import 'package:spotube/components/Player/PlayerControls.dart'; import 'package:spotube/components/Player/PlayerControls.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/SpotubeMarqueeText.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/useBreakpoints.dart';
import 'package:spotube/hooks/useCustomStatusBarColor.dart'; import 'package:spotube/hooks/useCustomStatusBarColor.dart';
import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/hooks/usePaletteColor.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerView extends HookConsumerWidget { class PlayerView extends HookConsumerWidget {
const PlayerView({ const PlayerView({
@ -39,7 +38,7 @@ class PlayerView extends HookConsumerWidget {
}, [breakpoint]); }, [breakpoint]);
String albumArt = useMemoized( String albumArt = useMemoized(
() => imageToUrlString( () => TypeConversionUtils.image_X_UrlString(
currentTrack?.album?.images, currentTrack?.album?.images,
index: (currentTrack?.album?.images?.length ?? 1) - 1, index: (currentTrack?.album?.images?.length ?? 1) - 1,
), ),
@ -100,7 +99,7 @@ class PlayerView extends HookConsumerWidget {
style: Theme.of(context).textTheme.headline5, style: Theme.of(context).textTheme.headline5,
), ),
), ),
artistsToClickableArtists( TypeConversionUtils.artists_X_ClickableArtists(
currentTrack?.artists ?? [], currentTrack?.artists ?? [],
textStyle: textStyle:
Theme.of(context).textTheme.headline6!.copyWith( Theme.of(context).textTheme.headline6!.copyWith(

View File

@ -3,11 +3,11 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/PlaybuttonCard.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/hooks/useBreakpointValue.dart';
import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCard extends HookConsumerWidget { class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist; final PlaylistSimple playlist;
@ -23,7 +23,7 @@ class PlaylistCard extends HookConsumerWidget {
return PlaybuttonCard( return PlaybuttonCard(
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
title: playlist.name!, title: playlist.name!,
imageUrl: imageToUrlString(playlist.images), imageUrl: TypeConversionUtils.image_X_UrlString(playlist.images),
isPlaying: isPlaylistPlaying, isPlaying: isPlaylistPlaying,
isLoading: playback.status == PlaybackStatus.loading && isPlaylistPlaying, isLoading: playback.status == PlaybackStatus.loading && isPlaylistPlaying,
onTap: () { onTap: () {
@ -52,7 +52,7 @@ class PlaylistCard extends HookConsumerWidget {
tracks: tracks, tracks: tracks,
id: playlist.id!, id: playlist.id!,
name: playlist.name!, name: playlist.name!,
thumbnail: imageToUrlString(playlist.images), thumbnail: TypeConversionUtils.image_X_UrlString(playlist.images),
), ),
); );
}, },

View File

@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/HeartButton.dart';
import 'package:spotube/components/Shared/TrackCollectionView.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/hooks/usePaletteColor.dart';
import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
@ -15,6 +14,7 @@ import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistView extends HookConsumerWidget { class PlaylistView extends HookConsumerWidget {
final logger = getLogger(PlaylistView); final logger = getLogger(PlaylistView);
@ -32,7 +32,7 @@ class PlaylistView extends HookConsumerWidget {
tracks: tracks, tracks: tracks,
id: playlist.id!, id: playlist.id!,
name: playlist.name!, name: playlist.name!,
thumbnail: imageToUrlString(playlist.images), thumbnail: TypeConversionUtils.image_X_UrlString(playlist.images),
), ),
tracks.indexWhere((s) => s.id == currentTrack?.id), tracks.indexWhere((s) => s.id == currentTrack?.id),
); );
@ -54,8 +54,9 @@ class PlaylistView extends HookConsumerWidget {
final meSnapshot = ref.watch(currentUserQuery); final meSnapshot = ref.watch(currentUserQuery);
final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!)); final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!));
final titleImage = final titleImage = useMemoized(
useMemoized(() => imageToUrlString(playlist.images), [playlist.images]); () => TypeConversionUtils.image_X_UrlString(playlist.images),
[playlist.images]);
final color = usePaletteGenerator( final color = usePaletteGenerator(
context, context,

View File

@ -7,14 +7,13 @@ import 'package:spotube/components/Artist/ArtistCard.dart';
import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart';
import 'package:spotube/components/Shared/AnonymousFallback.dart'; import 'package:spotube/components/Shared/AnonymousFallback.dart';
import 'package:spotube/components/Shared/TrackTile.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/hooks/useBreakpoints.dart';
import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyRequests.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) => ""); final searchTermStateProvider = StateProvider<String>((ref) => "");
@ -104,13 +103,14 @@ class Search extends HookConsumerWidget {
), ),
...tracks.asMap().entries.map((track) { ...tracks.asMap().entries.map((track) {
String duration = 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( return TrackTile(
playback, playback,
track: track, track: track,
duration: duration, duration: duration,
thumbnailUrl: thumbnailUrl:
imageToUrlString(track.value.album?.images), TypeConversionUtils.image_X_UrlString(
track.value.album?.images),
isActive: playback.track?.id == track.value.id, isActive: playback.track?.id == track.value.id,
onTrackPlayButtonPressed: (currentTrack) async { onTrackPlayButtonPressed: (currentTrack) async {
var isPlaylistPlaying = var isPlaylistPlaying =
@ -123,8 +123,10 @@ class Search extends HookConsumerWidget {
tracks: [currentTrack], tracks: [currentTrack],
id: currentTrack.id!, id: currentTrack.id!,
name: currentTrack.name!, name: currentTrack.name!,
thumbnail: imageToUrlString( thumbnail: TypeConversionUtils
currentTrack.album?.images), .image_X_UrlString(
currentTrack.album?.images,
),
), ),
); );
} else if (isPlaylistPlaying && } else if (isPlaylistPlaying &&
@ -148,7 +150,11 @@ class Search extends HookConsumerWidget {
controller: albumController, controller: albumController,
child: Row( child: Row(
children: albums.map((album) { children: albums.map((album) {
return AlbumCard(simpleAlbumToAlbum(album)); return AlbumCard(
TypeConversionUtils.simpleAlbum_X_Album(
album,
),
);
}).toList(), }).toList(),
), ),
), ),

View File

@ -5,10 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/Settings/About.dart'; import 'package:spotube/components/Settings/About.dart';
import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.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/SpotifyMarkets.dart';
import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:collection/collection.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( ListTile(
title: const Text("Download lyrics along with the Track"), title: const Text("Download lyrics along with the Track"),
horizontalTitleGap: 10, horizontalTitleGap: 10,

View File

@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.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/models/SpotubeTrack.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/utils/platform.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:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:path_provider/path_provider.dart' as path_provider; import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
@ -32,7 +32,7 @@ class DownloadTrackButton extends HookConsumerWidget {
final outputFile = useState<File?>(null); final outputFile = useState<File?>(null);
final downloadFolder = useState<String?>(null); final downloadFolder = useState<String?>(null);
String fileName = String fileName =
"${track?.name} - ${artistsToString<Artist>(track?.artists ?? [])}"; "${track?.name} - ${TypeConversionUtils.artists_X_String<Artist>(track?.artists ?? [])}";
useEffect(() { useEffect(() {
(() async { (() async {
@ -152,7 +152,7 @@ class DownloadTrackButton extends HookConsumerWidget {
if (!await outputLyricsFile.exists()) { if (!await outputLyricsFile.exists()) {
await outputLyricsFile.create(recursive: true); await outputLyricsFile.create(recursive: true);
} }
final lyrics = await getLyrics( final lyrics = await ServiceUtils.getLyrics(
playback.track!.name!, playback.track!.name!,
playback.track!.artists?.map((s) => s.name).whereNotNull().toList() ?? playback.track!.artists?.map((s) => s.name).whereNotNull().toList() ??
[], [],

View File

@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.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/useCustomStatusBarColor.dart';
import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/hooks/usePaletteColor.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
@ -225,7 +225,9 @@ class TrackCollectionView extends HookConsumerWidget {
return TracksTableView( return TracksTableView(
tracks is! List<Track> tracks is! List<Track>
? tracks ? tracks
.map((track) => simpleTrackToTrack(track, album!)) .map((track) =>
TypeConversionUtils.simpleTrack_X_Track(
track, album!))
.toList() .toList()
: tracks, : tracks,
onTrackPlayButtonPressed: onPlay, onTrackPlayButtonPressed: onPlay,

View File

@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/LinkText.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/useBreakpoints.dart';
import 'package:spotube/hooks/useForceUpdate.dart'; import 'package:spotube/hooks/useForceUpdate.dart';
import 'package:spotube/models/Logger.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/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class TrackTile extends HookConsumerWidget { class TrackTile extends HookConsumerWidget {
final Playback playback; final Playback playback;
@ -235,7 +235,8 @@ class TrackTile extends HookConsumerWidget {
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
artistsToClickableArtists(track.value.artists ?? [], TypeConversionUtils.artists_X_ClickableArtists(
track.value.artists ?? [],
textStyle: TextStyle( textStyle: TextStyle(
fontSize: fontSize:
breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)), breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)),

View File

@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/TrackTile.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/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.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 { class TracksTableView extends HookConsumerWidget {
final void Function(Track currentTrack)? onTrackPlayButtonPressed; final void Function(Track currentTrack)? onTrackPlayButtonPressed;
@ -79,12 +79,12 @@ class TracksTableView extends HookConsumerWidget {
], ],
), ),
...tracks.asMap().entries.map((track) { ...tracks.asMap().entries.map((track) {
String? thumbnailUrl = imageToUrlString( String? thumbnailUrl = TypeConversionUtils.image_X_UrlString(
track.value.album?.images, track.value.album?.images,
index: (track.value.album?.images?.length ?? 1) - 1, index: (track.value.album?.images?.length ?? 1) - 1,
); );
String duration = 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( return TrackTile(
playback, playback,
playlistId: playlistId, playlistId: playlistId,

View File

@ -22,6 +22,25 @@ class CacheTrackEngagement {
dislikeCount = engagement.dislikeCount; 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) @HiveType(typeId: 1)
class CacheTrack extends HiveObject { class CacheTrack extends HiveObject {
@HiveField(0) @HiveField(0)
@ -57,10 +76,16 @@ class CacheTrack extends HiveObject {
@HiveField(10) @HiveField(10)
late String author; late String author;
@HiveField(11)
late List<CacheTrackSkipSegment>? skipSegments;
CacheTrack(); CacheTrack();
CacheTrack.fromVideo(Video video, this.mode) CacheTrack.fromVideo(
: id = video.id.value, Video video,
this.mode, {
required List<Map<String, int>> skipSegments,
}) : id = video.id.value,
title = video.title, title = video.title,
author = video.author, author = video.author,
channelId = video.channelId.value, channelId = video.channelId.value,
@ -69,5 +94,8 @@ class CacheTrack extends HiveObject {
description = video.description, description = video.description,
duration = video.duration.toString(), duration = video.duration.toString(),
keywords = video.keywords, keywords = video.keywords,
engagement = CacheTrackEngagement.fromEngagement(video.engagement); engagement = CacheTrackEngagement.fromEngagement(video.engagement),
skipSegments = skipSegments
.map((segment) => CacheTrackSkipSegment.fromJson(segment))
.toList();
} }

View File

@ -45,6 +45,42 @@ class CacheTrackEngagementAdapter extends TypeAdapter<CacheTrackEngagement> {
typeId == other.typeId; 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> { class CacheTrackAdapter extends TypeAdapter<CacheTrack> {
@override @override
final int typeId = 1; final int typeId = 1;
@ -66,13 +102,14 @@ class CacheTrackAdapter extends TypeAdapter<CacheTrack> {
..keywords = (fields[7] as List?)?.cast<String>() ..keywords = (fields[7] as List?)?.cast<String>()
..engagement = fields[8] as CacheTrackEngagement ..engagement = fields[8] as CacheTrackEngagement
..mode = fields[9] as String ..mode = fields[9] as String
..author = fields[10] as String; ..author = fields[10] as String
..skipSegments = (fields[11] as List?)?.cast<CacheTrackSkipSegment>();
} }
@override @override
void write(BinaryWriter writer, CacheTrack obj) { void write(BinaryWriter writer, CacheTrack obj) {
writer writer
..writeByte(11) ..writeByte(12)
..writeByte(0) ..writeByte(0)
..write(obj.id) ..write(obj.id)
..writeByte(1) ..writeByte(1)
@ -94,7 +131,9 @@ class CacheTrackAdapter extends TypeAdapter<CacheTrack> {
..writeByte(9) ..writeByte(9)
..write(obj.mode) ..write(obj.mode)
..writeByte(10) ..writeByte(10)
..write(obj.author); ..write(obj.author)
..writeByte(11)
..write(obj.skipSegments);
} }
@override @override

View File

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

View File

@ -1,29 +0,0 @@
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/LinkText.dart';
Widget artistsToClickableArtists(
List<ArtistSimple> artists, {
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
WrapAlignment mainAxisAlignment = WrapAlignment.center,
TextStyle textStyle = const TextStyle(),
}) {
return Wrap(
crossAxisAlignment: crossAxisAlignment,
alignment: mainAxisAlignment,
children: artists
.asMap()
.entries
.map(
(artist) => LinkText(
(artist.key != artists.length - 1)
? "${artist.value.name}, "
: artist.value.name!,
"/artist/${artist.value.id}",
overflow: TextOverflow.ellipsis,
style: textStyle,
),
)
.toList(),
);
}

View File

@ -1,7 +0,0 @@
bool containsTextInBracket(String matcher, String text) {
final allMatches = RegExp(r"(?<=\().+?(?=\))").allMatches(matcher);
if (allMatches.isEmpty) return false;
return allMatches
.map((e) => e.group(0))
.every((match) => match?.contains(text) ?? false);
}

View File

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

View File

@ -1,166 +0,0 @@
import 'dart:convert';
import 'package:html/parser.dart' as parser;
import 'package:html/dom.dart';
import 'package:http/http.dart' as http;
import 'package:spotube/helpers/get-random-element.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/models/generated_secrets.dart';
import 'package:collection/collection.dart';
final logger = getLogger("GetLyrics");
String clearArtistsOfTitle(String title, List<String> artists) {
return title
.replaceAll(RegExp(artists.join("|"), caseSensitive: false), "")
.trim();
}
String getTitle(
String title, {
List<String> artists = const [],
bool onlyCleanArtist = false,
}) {
final match = RegExp(r"(?<=\().+?(?=\))").firstMatch(title)?.group(0);
final artistInBracket =
artists.any((artist) => match?.contains(artist) ?? false);
if (artistInBracket) {
title = title.replaceAll(
RegExp(" *\\([^)]*\\) *"),
'',
);
}
title = clearArtistsOfTitle(title, artists);
if (onlyCleanArtist) {
artists = [];
}
return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}"
.toLowerCase()
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
.replaceAll(RegExp("feat.|ft."), '')
.replaceAll(RegExp("\\s+"), ' ')
.trim();
}
Future<String?> extractLyrics(Uri url) async {
try {
var response = await http.get(url);
Document document = parser.parse(response.body);
var lyrics = document.querySelector('div.lyrics')?.text.trim();
if (lyrics == null) {
lyrics = "";
document
.querySelectorAll("div[class^=\"Lyrics__Container\"]")
.forEach((element) {
if (element.text.trim().isNotEmpty) {
var snippet = element.innerHtml.replaceAll("<br>", "\n").replaceAll(
RegExp("<(?!\\s*br\\s*\\/?)[^>]+>", caseSensitive: false),
"",
);
var el = document.createElement("textarea");
el.innerHtml = snippet;
lyrics = "$lyrics${el.text.trim()}\n\n";
}
});
}
return lyrics;
} catch (e, stack) {
logger.e("extractLyrics", e, stack);
rethrow;
}
}
Future<List?> searchSong(
String title,
List<String> artist, {
String? apiKey,
bool optimizeQuery = false,
bool authHeader = false,
}) async {
try {
if (apiKey == "" || apiKey == null) {
apiKey = getRandomElement(lyricsSecrets);
}
const searchUrl = 'https://api.genius.com/search?q=';
String song =
optimizeQuery ? getTitle(title, artists: artist) : "$title $artist";
String reqUrl = "$searchUrl${Uri.encodeComponent(song)}";
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
final response = await http.get(
Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"),
headers: authHeader ? headers : null,
);
Map data = jsonDecode(response.body)["response"];
if (data["hits"]?.length == 0) return null;
List results = data["hits"]?.map((val) {
return <String, dynamic>{
"id": val["result"]["id"],
"full_title": val["result"]["full_title"],
"albumArt": val["result"]["song_art_image_url"],
"url": val["result"]["url"],
"author": val["result"]["primary_artist"]["name"],
};
}).toList();
return results;
} catch (e, stack) {
logger.e("searchSong", e, stack);
rethrow;
}
}
Future<String?> getLyrics(
String title,
List<String> artists, {
required String apiKey,
bool optimizeQuery = false,
bool authHeader = false,
}) async {
try {
final results = await searchSong(
title,
artists,
apiKey: apiKey,
optimizeQuery: optimizeQuery,
authHeader: authHeader,
);
if (results == null) return null;
title = getTitle(
title,
artists: artists,
onlyCleanArtist: true,
).trim();
final ratedLyrics = results.map((result) {
final gTitle = (result["full_title"] as String).toLowerCase();
int points = 0;
final hasTitle = gTitle.contains(title);
final hasAllArtists =
artists.every((artist) => gTitle.contains(artist.toLowerCase()));
final String lyricAuthor = result["author"].toLowerCase();
final fromOriginalAuthor =
lyricAuthor.contains(artists.first.toLowerCase());
for (final criteria in [
hasTitle,
hasAllArtists,
fromOriginalAuthor,
]) {
if (criteria) points++;
}
return {"result": result, "points": points};
}).sorted(
(a, b) => ((a["points"] as int).compareTo(a["points"] as int)),
);
final worthyOne = ratedLyrics.first["result"];
String? lyrics = await extractLyrics(Uri.parse(worthyOne["url"]));
return lyrics;
} catch (e, stack) {
logger.e("getLyrics", e, stack);
return null;
}
}

View File

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

View File

@ -1,64 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Home/Home.dart';
import 'package:spotube/helpers/server_ipc.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Auth.dart';
const redirectUri = "http://localhost:4304/auth/spotify/callback";
final logger = getLogger("OAuthLogin");
Future<void> oauthLogin(Auth auth,
{required String clientId, required String clientSecret}) async {
try {
String? accessToken;
String? refreshToken;
DateTime? expiration;
final credentials = SpotifyApiCredentials(clientId, clientSecret);
final grant = SpotifyApi.authorizationCodeGrant(credentials);
final authUri = grant.getAuthorizationUrl(Uri.parse(redirectUri),
scopes: spotifyScopes);
final responseUri = await connectIpc(authUri.toString(), redirectUri);
SharedPreferences localStorage = await SharedPreferences.getInstance();
if (responseUri != null) {
final SpotifyApi spotify =
SpotifyApi.fromAuthCodeGrant(grant, responseUri);
final credentials = await spotify.getCredentials();
if (credentials.accessToken != null) {
accessToken = credentials.accessToken;
await localStorage.setString(
LocalStorageKeys.accessToken, credentials.accessToken!);
}
if (credentials.refreshToken != null) {
refreshToken = credentials.refreshToken;
await localStorage.setString(
LocalStorageKeys.refreshToken, credentials.refreshToken!);
}
if (credentials.expiration != null) {
expiration = credentials.expiration;
await localStorage.setString(LocalStorageKeys.expiration,
credentials.expiration?.toString() ?? "");
}
}
await localStorage.setString(LocalStorageKeys.clientId, clientId);
await localStorage.setString(
LocalStorageKeys.clientSecret,
clientSecret,
);
auth.setAuthState(
clientId: clientId,
clientSecret: clientSecret,
accessToken: accessToken,
refreshToken: refreshToken,
expiration: expiration,
);
} catch (e, stack) {
logger.e("oauthLogin", e, stack);
rethrow;
}
}

View File

@ -1,13 +0,0 @@
String toReadableNumber(double num) {
if (num > 999 && num < 99999) {
return "${(num / 1000).toStringAsFixed(0)}K";
} else if (num > 99999 && num < 999999) {
return "${(num / 1000).toStringAsFixed(0)}K";
} else if (num > 999999 && num < 999999999) {
return "${(num / 1000000).toStringAsFixed(0)}M";
} else if (num > 999999999) {
return "${(num / 1000000000).toStringAsFixed(0)}B";
} else {
return num.toString();
}
}

View File

@ -1,142 +0,0 @@
import 'dart:io';
import 'package:hive/hive.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/entities/CacheTrack.dart';
import 'package:spotube/helpers/contains-text-in-bracket.dart';
import 'package:spotube/helpers/getLyrics.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/models/SpotubeTrack.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:collection/collection.dart';
import 'package:spotube/extensions/list-sort-multiple.dart';
import 'package:spotube/extensions/yt-video-from-cache-track.dart';
enum AudioQuality {
high,
low,
}
final logger = getLogger("toSpotubeTrack");
Future<SpotubeTrack> toSpotubeTrack({
required YoutubeExplode youtube,
required Track track,
required String format,
required SpotubeTrackMatchAlgorithm matchAlgorithm,
required AudioQuality audioQuality,
LazyBox<CacheTrack>? box,
}) async {
final artistsName =
track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ??
[];
logger.v("[Track Search Artists] $artistsName");
final mainArtist = artistsName.first;
final featuredArtists =
artistsName.length > 1 ? "feat. " + artistsName.sublist(1).join(" ") : "";
final title = getTitle(
track.name!,
artists: artistsName,
onlyCleanArtist: true,
).trim();
logger.v("[Track Search Title] $title");
final queryString = format
.replaceAll("\$MAIN_ARTIST", mainArtist)
.replaceAll("\$TITLE", title)
.replaceAll("\$FEATURED_ARTISTS", featuredArtists);
logger.v("[Youtube Search Term] $queryString");
Video ytVideo;
final cachedTrack = await box?.get(track.id);
if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) {
logger.v(
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
);
ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack);
} else {
VideoSearchList videos = await youtube.search.search(queryString);
if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) {
List<Map> ratedRankedVideos = videos
.map((video) {
// the find should be lazy thus everything case insensitive
final ytTitle = video.title.toLowerCase();
final bool hasTitle = ytTitle.contains(title);
final bool hasAllArtists = track.artists?.every(
(artist) => ytTitle.contains(artist.name!.toLowerCase()),
) ??
false;
final bool authorIsArtist =
track.artists?.first.name?.toLowerCase() ==
video.author.toLowerCase();
final bool hasNoLiveInTitle =
!containsTextInBracket(ytTitle, "live");
int rate = 0;
for (final el in [
hasTitle,
hasAllArtists,
if (matchAlgorithm == SpotubeTrackMatchAlgorithm.authenticPopular)
authorIsArtist,
hasNoLiveInTitle,
!video.isLive,
]) {
if (el) rate++;
}
// can't let pass any non title matching track
if (!hasTitle) rate = rate - 2;
return {
"video": video,
"points": rate,
"views": video.engagement.viewCount,
};
})
.toList()
.sortByProperties(
[false, false],
["points", "views"],
);
ytVideo = ratedRankedVideos.first["video"] as Video;
} else {
ytVideo = videos.where((video) => !video.isLive).first;
}
}
final trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
logger.v(
"[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}",
);
final audioManifest = trackManifest.audioOnly.where((info) {
final isMp4a = info.codec.mimeType == "audio/mp4";
if (Platform.isLinux) {
return !isMp4a;
} else if (Platform.isMacOS || Platform.isIOS) {
return isMp4a;
} else {
return true;
}
});
final ytUri = (audioQuality == AudioQuality.high
? audioManifest.withHighestBitrate()
: audioManifest.sortByBitrate().last)
.url
.toString();
// only save when the track isn't available in the cache with same
// matchAlgorithm
if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) {
await box?.put(
track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name));
}
return SpotubeTrack.fromTrack(
track: track,
ytTrack: ytVideo,
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
// codec/mimetype for those Platforms
ytUri: ytUri,
);
}

View File

@ -1,44 +0,0 @@
import 'dart:io';
import 'package:spotube/models/Logger.dart';
import 'package:url_launcher/url_launcher.dart';
final logger = getLogger("ServerIPC");
Future<String?> connectIpc(String authUri, String redirectUri) async {
try {
logger.i("[Launching]: $authUri");
await launchUrl(
Uri.parse(authUri),
mode: LaunchMode.externalApplication,
);
HttpServer server =
await HttpServer.bind(InternetAddress.loopbackIPv4, 4304);
logger.i("Server started");
await for (HttpRequest request in server) {
if (request.uri.path == "/auth/spotify/callback" &&
request.method == "GET") {
String? code = request.uri.queryParameters["code"];
if (code != null) {
request.response
..statusCode = HttpStatus.ok
..write("Authentication successful. Now Go back to Spotube")
..close();
return "$redirectUri?code=$code";
} else {
request.response
..statusCode = HttpStatus.forbidden
..write("Authorization failed start over!")
..close();
throw Exception("No code provided");
}
}
}
} catch (e, stack) {
logger.e("connectIpc", e, stack);
rethrow;
}
return null;
}

View File

@ -1,19 +0,0 @@
import 'package:spotify/spotify.dart';
Album simpleAlbumToAlbum(AlbumSimple albumSimple) {
Album album = Album();
album.albumType = albumSimple.albumType;
album.artists = albumSimple.artists;
album.availableMarkets = albumSimple.availableMarkets;
album.externalUrls = albumSimple.externalUrls;
album.href = albumSimple.href;
album.id = albumSimple.id;
album.images = albumSimple.images;
album.name = albumSimple.name;
album.releaseDate = albumSimple.releaseDate;
album.releaseDatePrecision = albumSimple.releaseDatePrecision;
album.tracks = albumSimple.tracks;
album.type = albumSimple.type;
album.uri = albumSimple.uri;
return album;
}

View File

@ -1,23 +0,0 @@
import 'package:spotify/spotify.dart';
Track simpleTrackToTrack(TrackSimple trackSmp, AlbumSimple album) {
Track track = Track();
track.name = trackSmp.name;
track.album = album;
track.artists = trackSmp.artists;
track.availableMarkets = trackSmp.availableMarkets;
track.discNumber = trackSmp.discNumber;
track.durationMs = trackSmp.durationMs;
track.explicit = trackSmp.explicit;
track.externalUrls = trackSmp.externalUrls;
track.href = trackSmp.href;
track.id = trackSmp.id;
track.isPlayable = trackSmp.isPlayable;
track.linkedFrom = trackSmp.linkedFrom;
track.name = trackSmp.name;
track.previewUrl = trackSmp.previewUrl;
track.trackNumber = trackSmp.trackNumber;
track.type = trackSmp.type;
track.uri = trackSmp.uri;
return track;
}

View File

@ -1,122 +0,0 @@
import 'package:html/dom.dart';
import 'package:http/http.dart' as http;
import 'package:html/parser.dart';
import 'package:collection/collection.dart';
import 'package:spotube/helpers/contains-text-in-bracket.dart';
import 'package:spotube/helpers/getLyrics.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/models/SpotubeTrack.dart';
final logger = getLogger("getTimedLyrics");
class SubtitleSimple {
Uri uri;
String name;
List<LyricSlice> lyrics;
int rating;
SubtitleSimple({
required this.uri,
required this.name,
required this.lyrics,
required this.rating,
});
}
class LyricSlice {
Duration time;
String text;
LyricSlice({required this.time, required this.text});
@override
String toString() {
return "LyricsSlice({time: $time, text: $text})";
}
}
const baseUri = "https://www.rentanadviser.com/subtitles";
Future<SubtitleSimple?> getTimedLyrics(SpotubeTrack track) async {
final artistNames =
track.artists?.map((artist) => artist.name!).toList() ?? [];
final query = getTitle(
track.name!,
artists: artistNames,
);
logger.v("[Searching Subtitle] $query");
final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace(
queryParameters: {"q": query},
);
final res = await http.get(searchUri);
final document = parse(res.body);
final results =
document.querySelectorAll("#tablecontainer table tbody tr td a");
final rateSortedResults = results.map((result) {
final title = result.text.trim().toLowerCase();
int points = 0;
final hasAllArtists = track.artists
?.map((artist) => artist.name!)
.every((artist) => title.contains(artist.toLowerCase())) ??
false;
final hasTrackName = title.contains(track.name!.toLowerCase());
final isNotLive = !containsTextInBracket(title, "live");
final exactYtMatch = title == track.ytTrack.title.toLowerCase();
if (exactYtMatch) points = 7;
for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) {
if (criteria) points++;
}
return {"result": result, "points": points};
}).sorted((a, b) => (b["points"] as int).compareTo(a["points"] as int));
// not result was found at all
if (rateSortedResults.first["points"] == 0) {
logger.e("[Subtitle not found] ${track.name}");
return Future.error("Subtitle lookup failed", StackTrace.current);
}
final topResult = rateSortedResults.first["result"] as Element;
final subtitleUri =
Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc");
logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri");
final lrcDocument = parse((await http.get(subtitleUri)).body);
final lrcList = lrcDocument
.querySelector("#ctl00_ContentPlaceHolder1_lbllyrics")
?.innerHtml
.replaceAll(RegExp(r'<h3>.*</h3>'), "")
.split("<br>")
.map((e) {
e = e.trim();
final regexp = RegExp(r'\[.*\]');
final timeStr = regexp
.firstMatch(e)
?.group(0)
?.replaceAll(RegExp(r'\[|\]'), "")
.trim()
.split(":");
final minuteSeconds = timeStr?.last.split(".");
return LyricSlice(
time: Duration(
minutes: int.parse(timeStr?.first ?? "0"),
seconds: int.parse(minuteSeconds?.first ?? "0"),
milliseconds: int.parse(minuteSeconds?.last ?? "0"),
),
text: e.split(regexp).last);
}).toList() ??
[];
final subtitle = SubtitleSimple(
name: topResult.text.trim(),
uri: subtitleUri,
lyrics: lrcList,
rating: rateSortedResults.first["points"] as int,
);
return subtitle;
}

View File

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

View File

@ -25,6 +25,7 @@ void main() async {
await Hive.initFlutter(); await Hive.initFlutter();
Hive.registerAdapter(CacheTrackAdapter()); Hive.registerAdapter(CacheTrackAdapter());
Hive.registerAdapter(CacheTrackEngagementAdapter()); Hive.registerAdapter(CacheTrackEngagementAdapter());
Hive.registerAdapter(CacheTrackSkipSegmentAdapter());
if (kIsDesktop) { if (kIsDesktop) {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
doWhenWindowReady(() async { doWhenWindowReady(() async {
@ -46,7 +47,7 @@ void main() async {
} }
MobileAudioService? audioServiceHandler; MobileAudioService? audioServiceHandler;
runApp(ProviderScope( runApp(ProviderScope(
child: Spotube(), child: const Spotube(),
overrides: [ overrides: [
playbackProvider.overrideWithProvider(ChangeNotifierProvider( playbackProvider.overrideWithProvider(ChangeNotifierProvider(
(ref) { (ref) {

View File

@ -0,0 +1,24 @@
class SubtitleSimple {
Uri uri;
String name;
List<LyricSlice> lyrics;
int rating;
SubtitleSimple({
required this.uri,
required this.name,
required this.lyrics,
required this.rating,
});
}
class LyricSlice {
Duration time;
String text;
LyricSlice({required this.time, required this.text});
@override
String toString() {
return "LyricsSlice({time: $time, text: $text})";
}
}

View File

@ -15,16 +15,19 @@ enum SpotubeTrackMatchAlgorithm {
class SpotubeTrack extends Track { class SpotubeTrack extends Track {
Video ytTrack; Video ytTrack;
String ytUri; String ytUri;
List<Map<String, int>> skipSegments;
SpotubeTrack( SpotubeTrack(
this.ytTrack, this.ytTrack,
this.ytUri, this.ytUri,
this.skipSegments,
) : super(); ) : super();
SpotubeTrack.fromTrack({ SpotubeTrack.fromTrack({
required Track track, required Track track,
required this.ytTrack, required this.ytTrack,
required this.ytUri, required this.ytUri,
required this.skipSegments,
}) : super() { }) : super() {
album = track.album; album = track.album;
artists = track.artists; artists = track.artists;
@ -51,6 +54,8 @@ class SpotubeTrack extends Track {
track: Track.fromJson(map), track: Track.fromJson(map),
ytTrack: VideoToJson.fromJson(map["ytTrack"]), ytTrack: VideoToJson.fromJson(map["ytTrack"]),
ytUri: map["ytUri"], ytUri: map["ytUri"],
skipSegments:
List.castFrom<dynamic, Map<String, int>>(map["skipSegments"]),
); );
} }
@ -74,6 +79,7 @@ class SpotubeTrack extends Track {
"uri": uri, "uri": uri,
"ytTrack": ytTrack.toJson(), "ytTrack": ytTrack.toJson(),
"ytUri": ytUri, "ytUri": ytUri,
"skipSegments": skipSegments
}; };
} }
} }

View File

@ -9,11 +9,6 @@ import 'package:hive/hive.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/entities/CacheTrack.dart';
import 'package:spotube/extensions/yt-video-from-cache-track.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/CurrentPlaylist.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/models/SpotubeTrack.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/LinuxAudioService.dart';
import 'package:spotube/services/MobileAudioService.dart'; import 'package:spotube/services/MobileAudioService.dart';
import 'package:spotube/utils/PersistedChangeNotifier.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:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:spotube/extensions/list-sort-multiple.dart'; import 'package:spotube/extensions/list-sort-multiple.dart';
import 'package:http/http.dart' as http;
enum PlaybackStatus { enum PlaybackStatus {
playing, playing,
@ -33,6 +32,11 @@ enum PlaybackStatus {
idle, idle,
} }
enum AudioQuality {
high,
low,
}
class Playback extends PersistedChangeNotifier { class Playback extends PersistedChangeNotifier {
// player properties // player properties
bool isShuffled; bool isShuffled;
@ -114,14 +118,14 @@ class Playback extends PersistedChangeNotifier {
} }
final currentTrackIndex = 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 // when the track progress is above 80%, track isn't the last
// and is not already fetch and nothing is fetching currently // and is not already fetch and nothing is fetching currently
if (pos.inSeconds > currentDuration.inSeconds * .8 && if (pos.inSeconds > currentDuration.inSeconds * .8 &&
playlist != null && playlist != null &&
currentTrackIndex != playlist!.tracks.length - 1 && currentTrackIndex != playlist!.tracks.length - 1 &&
playlist!.tracks.elementAt(currentTrackIndex + 1) playlist!.tracks.elementAt(currentTrackIndex! + 1)
is! SpotubeTrack && is! SpotubeTrack &&
!_isPreSearching) { !_isPreSearching) {
_isPreSearching = true; _isPreSearching = true;
@ -132,6 +136,15 @@ class Playback extends PersistedChangeNotifier {
return v; 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!, id: track.id!,
title: track.name!, title: track.name!,
album: track.album?.name, album: track.album?.name,
artist: artistsToString(track.artists ?? <ArtistSimple>[]), artist: TypeConversionUtils.artists_X_String(
artUri: Uri.parse(imageToUrlString(track.album?.images)), track.artists ?? <ArtistSimple>[]),
artUri: Uri.parse(
TypeConversionUtils.image_X_UrlString(track.album?.images)),
duration: track.ytTrack.duration, duration: track.ytTrack.duration,
); );
mobileAudioService?.addItem(tag); 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 // playlist & track list methods
Future<SpotubeTrack> toSpotubeTrack(Track track) async { Future<SpotubeTrack> toSpotubeTrack(Track track) async {
try {
final format = preferences.ytSearchFormat; final format = preferences.ytSearchFormat;
final matchAlgorithm = preferences.trackMatchAlgorithm; final matchAlgorithm = preferences.trackMatchAlgorithm;
final artistsName = final artistsName = track.artists
track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ?? ?.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList() ??
[]; [];
final audioQuality = preferences.audioQuality; final audioQuality = preferences.audioQuality;
_logger.v("[Track Search Artists] $artistsName"); _logger.v("[Track Search Artists] $artistsName");
@ -287,7 +344,7 @@ class Playback extends PersistedChangeNotifier {
final featuredArtists = artistsName.length > 1 final featuredArtists = artistsName.length > 1
? "feat. " + artistsName.sublist(1).join(" ") ? "feat. " + artistsName.sublist(1).join(" ")
: ""; : "";
final title = getTitle( final title = ServiceUtils.getTitle(
track.name!, track.name!,
artists: artistsName, artists: artistsName,
onlyCleanArtist: true, onlyCleanArtist: true,
@ -324,7 +381,7 @@ class Playback extends PersistedChangeNotifier {
video.author.toLowerCase(); video.author.toLowerCase();
final bool hasNoLiveInTitle = final bool hasNoLiveInTitle =
!containsTextInBracket(ytTitle, "live"); !PrimitiveUtils.containsTextInBracket(ytTitle, "live");
int rate = 0; int rate = 0;
for (final el in [ for (final el in [
@ -383,11 +440,26 @@ class Playback extends PersistedChangeNotifier {
.url .url
.toString(); .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 // only save when the track isn't available in the cache with same
// matchAlgorithm // matchAlgorithm
if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) { if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) {
await cache.put( await cache.put(
track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name)); track.id!,
CacheTrack.fromVideo(
ytVideo,
matchAlgorithm.name,
skipSegments: skipSegments,
),
);
} }
return SpotubeTrack.fromTrack( return SpotubeTrack.fromTrack(
@ -397,7 +469,12 @@ class Playback extends PersistedChangeNotifier {
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
// codec/mimetype for those Platforms // codec/mimetype for those Platforms
ytUri: ytUri, ytUri: ytUri,
skipSegments: skipSegments,
); );
} catch (e, stack) {
_logger.e("topSpotubeTrack", e, stack);
rethrow;
}
} }
Future<void> setPlaylistPosition(int position) async { Future<void> setPlaylistPosition(int position) async {
@ -429,13 +506,24 @@ class Playback extends PersistedChangeNotifier {
@override @override
FutureOr<void> loadFromLocal(Map<String, dynamic> map) async { FutureOr<void> loadFromLocal(Map<String, dynamic> map) async {
try {
if (map["playlist"] != null) { if (map["playlist"] != null) {
playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"])); playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"]));
} }
if (map["track"] != null) { 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; volume = map["volume"] ?? volume;
} catch (e) {
_logger.e("loadFromLocal", e);
}
} }
@override @override

View File

@ -1,13 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Home/Home.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/models/generated_secrets.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/utils/primitive_utils.dart';
final spotifyProvider = Provider<SpotifyApi>((ref) { final spotifyProvider = Provider<SpotifyApi>((ref) {
Auth authState = ref.watch(authProvider); Auth authState = ref.watch(authProvider);
final anonCred = getRandomElement(spotifySecrets); final anonCred = PrimitiveUtils.getRandomElement(spotifySecrets);
SpotifyApiCredentials apiCredentials = authState.isAnonymous SpotifyApiCredentials apiCredentials = authState.isAnonymous
? SpotifyApiCredentials( ? SpotifyApiCredentials(
anonCred["clientId"], anonCred["clientId"],

View File

@ -1,15 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/helpers/getLyrics.dart'; import 'package:spotube/models/LyricsModels.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/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
import 'package:collection/collection.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>( final categoriesQuery = FutureProvider.family<Page<Category>, int>(
(ref, pageKey) { (ref, pageKey) {
@ -134,7 +133,7 @@ final currentUserQuery = FutureProvider<User>(
Image() Image()
..height = 50 ..height = 50
..width = 50 ..width = 50
..url = imageToUrlString(me.images), ..url = TypeConversionUtils.image_X_UrlString(me.images),
]; ];
} }
return me; return me;
@ -172,7 +171,7 @@ final geniusLyricsQuery = FutureProvider<String?>(
if (currentTrack == null) { if (currentTrack == null) {
return "“Give this player a track to play”\n- S'Challa"; return "“Give this player a track to play”\n- S'Challa";
} }
return getLyrics( return ServiceUtils.getLyrics(
currentTrack.name!, currentTrack.name!,
currentTrack.artists?.map((s) => s.name).whereNotNull().toList() ?? [], currentTrack.artists?.map((s) => s.name).whereNotNull().toList() ?? [],
apiKey: geniusAccessToken, apiKey: geniusAccessToken,
@ -185,6 +184,6 @@ final rentanadviserLyricsQuery = FutureProvider<SubtitleSimple?>(
(ref) { (ref) {
final currentTrack = ref.watch(playbackProvider.select((s) => s.track)); final currentTrack = ref.watch(playbackProvider.select((s) => s.track));
if (currentTrack == null) return null; if (currentTrack == null) return null;
return getTimedLyrics(currentTrack as SpotubeTrack); return ServiceUtils.getTimedLyrics(currentTrack);
}, },
); );

View File

@ -3,12 +3,12 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/components/Settings/ColorSchemePickerDialog.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/SpotubeTrack.dart';
import 'package:spotube/models/generated_secrets.dart'; import 'package:spotube/models/generated_secrets.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/utils/PersistedChangeNotifier.dart'; import 'package:spotube/utils/PersistedChangeNotifier.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:spotube/utils/primitive_utils.dart';
class UserPreferences extends PersistedChangeNotifier { class UserPreferences extends PersistedChangeNotifier {
ThemeMode themeMode; ThemeMode themeMode;
@ -22,6 +22,7 @@ class UserPreferences extends PersistedChangeNotifier {
MaterialColor accentColorScheme; MaterialColor accentColorScheme;
MaterialColor backgroundColorScheme; MaterialColor backgroundColorScheme;
bool skipSponsorSegments;
UserPreferences({ UserPreferences({
required this.geniusAccessToken, required this.geniusAccessToken,
required this.recommendationMarket, required this.recommendationMarket,
@ -33,6 +34,7 @@ class UserPreferences extends PersistedChangeNotifier {
this.checkUpdate = true, this.checkUpdate = true,
this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular, this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular,
this.audioQuality = AudioQuality.high, this.audioQuality = AudioQuality.high,
this.skipSponsorSegments = true,
}) : super(); }) : super();
void setThemeMode(ThemeMode mode) { void setThemeMode(ThemeMode mode) {
@ -95,13 +97,19 @@ class UserPreferences extends PersistedChangeNotifier {
updatePersistence(); updatePersistence();
} }
void setSkipSponsorSegments(bool should) {
skipSponsorSegments = should;
notifyListeners();
updatePersistence();
}
@override @override
FutureOr<void> loadFromLocal(Map<String, dynamic> map) { FutureOr<void> loadFromLocal(Map<String, dynamic> map) {
saveTrackLyrics = map["saveTrackLyrics"] ?? false; saveTrackLyrics = map["saveTrackLyrics"] ?? false;
recommendationMarket = map["recommendationMarket"] ?? recommendationMarket; recommendationMarket = map["recommendationMarket"] ?? recommendationMarket;
checkUpdate = map["checkUpdate"] ?? checkUpdate; checkUpdate = map["checkUpdate"] ?? checkUpdate;
geniusAccessToken = geniusAccessToken = map["geniusAccessToken"] ??
map["geniusAccessToken"] ?? getRandomElement(lyricsSecrets); PrimitiveUtils.getRandomElement(lyricsSecrets);
ytSearchFormat = map["ytSearchFormat"] ?? ytSearchFormat; ytSearchFormat = map["ytSearchFormat"] ?? ytSearchFormat;
themeMode = ThemeMode.values[map["themeMode"] ?? 0]; themeMode = ThemeMode.values[map["themeMode"] ?? 0];
@ -117,6 +125,7 @@ class UserPreferences extends PersistedChangeNotifier {
audioQuality = map["audioQuality"] != null audioQuality = map["audioQuality"] != null
? AudioQuality.values[map["audioQuality"]] ? AudioQuality.values[map["audioQuality"]]
: audioQuality; : audioQuality;
skipSponsorSegments = map["skipSponsorSegments"] ?? skipSponsorSegments;
} }
@override @override
@ -132,6 +141,7 @@ class UserPreferences extends PersistedChangeNotifier {
"checkUpdate": checkUpdate, "checkUpdate": checkUpdate,
"trackMatchAlgorithm": trackMatchAlgorithm.index, "trackMatchAlgorithm": trackMatchAlgorithm.index,
"audioQuality": audioQuality.index, "audioQuality": audioQuality.index,
"skipSponsorSegments": skipSponsorSegments,
}; };
} }
} }

View File

@ -4,9 +4,9 @@ import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:dbus/dbus.dart'; import 'package:dbus/dbus.dart';
import 'package:spotube/provider/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/models/SpotubeTrack.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class _MprisMediaPlayer2 extends DBusObject { class _MprisMediaPlayer2 extends DBusObject {
/// Creates a new object to expose on [path]. /// Creates a new object to expose on [path].
@ -296,8 +296,10 @@ class _MprisMediaPlayer2Player extends DBusObject {
DBusDict.stringVariant({ DBusDict.stringVariant({
"mpris:trackid": DBusString("${path.value}/Track/$id"), "mpris:trackid": DBusString("${path.value}/Track/$id"),
"mpris:length": DBusInt32(playback.currentDuration.inMicroseconds), "mpris:length": DBusInt32(playback.currentDuration.inMicroseconds),
"mpris:artUrl": "mpris:artUrl": DBusString(
DBusString(imageToUrlString(playback.track?.album?.images)), TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images),
),
"xesam:album": DBusString(playback.track!.album!.name!), "xesam:album": DBusString(playback.track!.album!.name!),
"xesam:artist": DBusArray.string( "xesam:artist": DBusArray.string(
playback.track!.artists!.map((artist) => artist.name!), playback.track!.artists!.map((artist) => artist.name!),

View File

@ -0,0 +1,37 @@
import 'dart:math';
import 'package:uuid/uuid.dart';
abstract class PrimitiveUtils {
static bool containsTextInBracket(String matcher, String text) {
final allMatches = RegExp(r"(?<=\().+?(?=\))").allMatches(matcher);
if (allMatches.isEmpty) return false;
return allMatches
.map((e) => e.group(0))
.every((match) => match?.contains(text) ?? false);
}
static final Random _random = Random();
static T getRandomElement<T>(List<T> list) {
return list[_random.nextInt(list.length)];
}
static const uuid = Uuid();
static String toReadableNumber(double num) {
if (num > 999 && num < 99999) {
return "${(num / 1000).toStringAsFixed(0)}K";
} else if (num > 99999 && num < 999999) {
return "${(num / 1000).toStringAsFixed(0)}K";
} else if (num > 999999 && num < 999999999) {
return "${(num / 1000000).toStringAsFixed(0)}M";
} else if (num > 999999999) {
return "${(num / 1000000000).toStringAsFixed(0)}B";
} else {
return num.toString();
}
}
static String zeroPadNumStr(int input) {
return input < 10 ? "0$input" : input.toString();
}
}

View File

@ -0,0 +1,359 @@
import 'dart:convert';
import 'dart:io';
import 'package:html/dom.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Home/Home.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/models/Logger.dart';
import 'package:http/http.dart' as http;
import 'package:spotube/models/LyricsModels.dart';
import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotube/models/generated_secrets.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:collection/collection.dart';
import 'package:html/parser.dart' as parser;
import 'package:url_launcher/url_launcher.dart';
abstract class ServiceUtils {
static final logger = getLogger("ServiceUtils");
static String clearArtistsOfTitle(String title, List<String> artists) {
return title
.replaceAll(RegExp(artists.join("|"), caseSensitive: false), "")
.trim();
}
static String getTitle(
String title, {
List<String> artists = const [],
bool onlyCleanArtist = false,
}) {
final match = RegExp(r"(?<=\().+?(?=\))").firstMatch(title)?.group(0);
final artistInBracket =
artists.any((artist) => match?.contains(artist) ?? false);
if (artistInBracket) {
title = title.replaceAll(
RegExp(" *\\([^)]*\\) *"),
'',
);
}
title = clearArtistsOfTitle(title, artists);
if (onlyCleanArtist) {
artists = [];
}
return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}"
.toLowerCase()
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
.replaceAll(RegExp("feat.|ft."), '')
.replaceAll(RegExp("\\s+"), ' ')
.trim();
}
static Future<String?> extractLyrics(Uri url) async {
try {
var response = await http.get(url);
Document document = parser.parse(response.body);
var lyrics = document.querySelector('div.lyrics')?.text.trim();
if (lyrics == null) {
lyrics = "";
document
.querySelectorAll("div[class^=\"Lyrics__Container\"]")
.forEach((element) {
if (element.text.trim().isNotEmpty) {
var snippet = element.innerHtml.replaceAll("<br>", "\n").replaceAll(
RegExp("<(?!\\s*br\\s*\\/?)[^>]+>", caseSensitive: false),
"",
);
var el = document.createElement("textarea");
el.innerHtml = snippet;
lyrics = "$lyrics${el.text.trim()}\n\n";
}
});
}
return lyrics;
} catch (e, stack) {
logger.e("extractLyrics", e, stack);
rethrow;
}
}
static Future<List?> searchSong(
String title,
List<String> artist, {
String? apiKey,
bool optimizeQuery = false,
bool authHeader = false,
}) async {
try {
if (apiKey == "" || apiKey == null) {
apiKey = PrimitiveUtils.getRandomElement(lyricsSecrets);
}
const searchUrl = 'https://api.genius.com/search?q=';
String song =
optimizeQuery ? getTitle(title, artists: artist) : "$title $artist";
String reqUrl = "$searchUrl${Uri.encodeComponent(song)}";
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
final response = await http.get(
Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"),
headers: authHeader ? headers : null,
);
Map data = jsonDecode(response.body)["response"];
if (data["hits"]?.length == 0) return null;
List results = data["hits"]?.map((val) {
return <String, dynamic>{
"id": val["result"]["id"],
"full_title": val["result"]["full_title"],
"albumArt": val["result"]["song_art_image_url"],
"url": val["result"]["url"],
"author": val["result"]["primary_artist"]["name"],
};
}).toList();
return results;
} catch (e, stack) {
logger.e("searchSong", e, stack);
rethrow;
}
}
static Future<String?> getLyrics(
String title,
List<String> artists, {
required String apiKey,
bool optimizeQuery = false,
bool authHeader = false,
}) async {
try {
final results = await searchSong(
title,
artists,
apiKey: apiKey,
optimizeQuery: optimizeQuery,
authHeader: authHeader,
);
if (results == null) return null;
title = getTitle(
title,
artists: artists,
onlyCleanArtist: true,
).trim();
final ratedLyrics = results.map((result) {
final gTitle = (result["full_title"] as String).toLowerCase();
int points = 0;
final hasTitle = gTitle.contains(title);
final hasAllArtists =
artists.every((artist) => gTitle.contains(artist.toLowerCase()));
final String lyricAuthor = result["author"].toLowerCase();
final fromOriginalAuthor =
lyricAuthor.contains(artists.first.toLowerCase());
for (final criteria in [
hasTitle,
hasAllArtists,
fromOriginalAuthor,
]) {
if (criteria) points++;
}
return {"result": result, "points": points};
}).sorted(
(a, b) => ((a["points"] as int).compareTo(a["points"] as int)),
);
final worthyOne = ratedLyrics.first["result"];
String? lyrics = await extractLyrics(Uri.parse(worthyOne["url"]));
return lyrics;
} catch (e, stack) {
logger.e("getLyrics", e, stack);
return null;
}
}
static Future<String?> connectIpc(String authUri, String redirectUri) async {
try {
logger.i("[connectIpc][Launching]: $authUri");
await launchUrl(
Uri.parse(authUri),
mode: LaunchMode.externalApplication,
);
HttpServer server =
await HttpServer.bind(InternetAddress.loopbackIPv4, 4304);
logger.i("[connectIpc] Server started");
await for (HttpRequest request in server) {
if (request.uri.path == "/auth/spotify/callback" &&
request.method == "GET") {
String? code = request.uri.queryParameters["code"];
if (code != null) {
request.response
..statusCode = HttpStatus.ok
..write("Authentication successful. Now Go back to Spotube")
..close();
return "$redirectUri?code=$code";
} else {
request.response
..statusCode = HttpStatus.forbidden
..write("Authorization failed start over!")
..close();
throw Exception("No code provided");
}
}
}
} catch (e, stack) {
logger.e("connectIpc", e, stack);
rethrow;
}
return null;
}
static const authRedirectUri = "http://localhost:4304/auth/spotify/callback";
static Future<void> oauthLogin(Auth auth,
{required String clientId, required String clientSecret}) async {
try {
String? accessToken;
String? refreshToken;
DateTime? expiration;
final credentials = SpotifyApiCredentials(clientId, clientSecret);
final grant = SpotifyApi.authorizationCodeGrant(credentials);
final authUri = grant.getAuthorizationUrl(Uri.parse(authRedirectUri),
scopes: spotifyScopes);
final responseUri = await connectIpc(authUri.toString(), authRedirectUri);
SharedPreferences localStorage = await SharedPreferences.getInstance();
if (responseUri != null) {
final SpotifyApi spotify =
SpotifyApi.fromAuthCodeGrant(grant, responseUri);
final credentials = await spotify.getCredentials();
if (credentials.accessToken != null) {
accessToken = credentials.accessToken;
await localStorage.setString(
LocalStorageKeys.accessToken, credentials.accessToken!);
}
if (credentials.refreshToken != null) {
refreshToken = credentials.refreshToken;
await localStorage.setString(
LocalStorageKeys.refreshToken, credentials.refreshToken!);
}
if (credentials.expiration != null) {
expiration = credentials.expiration;
await localStorage.setString(LocalStorageKeys.expiration,
credentials.expiration?.toString() ?? "");
}
}
await localStorage.setString(LocalStorageKeys.clientId, clientId);
await localStorage.setString(
LocalStorageKeys.clientSecret,
clientSecret,
);
auth.setAuthState(
clientId: clientId,
clientSecret: clientSecret,
accessToken: accessToken,
refreshToken: refreshToken,
expiration: expiration,
);
} catch (e, stack) {
logger.e("oauthLogin", e, stack);
rethrow;
}
}
static const baseUri = "https://www.rentanadviser.com/subtitles";
static Future<SubtitleSimple?> getTimedLyrics(SpotubeTrack track) async {
final artistNames =
track.artists?.map((artist) => artist.name!).toList() ?? [];
final query = getTitle(
track.name!,
artists: artistNames,
);
logger.v("[Searching Subtitle] $query");
final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace(
queryParameters: {"q": query},
);
final res = await http.get(searchUri);
final document = parser.parse(res.body);
final results =
document.querySelectorAll("#tablecontainer table tbody tr td a");
final rateSortedResults = results.map((result) {
final title = result.text.trim().toLowerCase();
int points = 0;
final hasAllArtists = track.artists
?.map((artist) => artist.name!)
.every((artist) => title.contains(artist.toLowerCase())) ??
false;
final hasTrackName = title.contains(track.name!.toLowerCase());
final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live");
final exactYtMatch = title == track.ytTrack.title.toLowerCase();
if (exactYtMatch) points = 7;
for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) {
if (criteria) points++;
}
return {"result": result, "points": points};
}).sorted((a, b) => (b["points"] as int).compareTo(a["points"] as int));
// not result was found at all
if (rateSortedResults.first["points"] == 0) {
logger.e("[Subtitle not found] ${track.name}");
return Future.error("Subtitle lookup failed", StackTrace.current);
}
final topResult = rateSortedResults.first["result"] as Element;
final subtitleUri =
Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc");
logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri");
final lrcDocument = parser.parse((await http.get(subtitleUri)).body);
final lrcList = lrcDocument
.querySelector("#ctl00_ContentPlaceHolder1_lbllyrics")
?.innerHtml
.replaceAll(RegExp(r'<h3>.*</h3>'), "")
.split("<br>")
.map((e) {
e = e.trim();
final regexp = RegExp(r'\[.*\]');
final timeStr = regexp
.firstMatch(e)
?.group(0)
?.replaceAll(RegExp(r'\[|\]'), "")
.trim()
.split(":");
final minuteSeconds = timeStr?.last.split(".");
return LyricSlice(
time: Duration(
minutes: int.parse(timeStr?.first ?? "0"),
seconds: int.parse(minuteSeconds?.first ?? "0"),
milliseconds: int.parse(minuteSeconds?.last ?? "0"),
),
text: e.split(regexp).last);
}).toList() ??
[];
final subtitle = SubtitleSimple(
name: topResult.text.trim(),
uri: subtitleUri,
lyrics: lrcList,
rating: rateSortedResults.first["points"] as int,
);
return subtitle;
}
}

View File

@ -0,0 +1,84 @@
// ignore_for_file: non_constant_identifier_names
import 'package:flutter/widgets.dart' hide Image;
import 'package:spotube/components/Shared/LinkText.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/utils/primitive_utils.dart';
abstract class TypeConversionUtils {
static String image_X_UrlString(List<Image>? images, {int index = 0}) {
return images != null && images.isNotEmpty
? images[0].url!
: "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png";
}
static String artists_X_String<T extends ArtistSimple>(List<T> artists) {
return artists.map((e) => e.name?.replaceAll(",", " ")).join(", ");
}
static Widget artists_X_ClickableArtists(
List<ArtistSimple> artists, {
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
WrapAlignment mainAxisAlignment = WrapAlignment.center,
TextStyle textStyle = const TextStyle(),
}) {
return Wrap(
crossAxisAlignment: crossAxisAlignment,
alignment: mainAxisAlignment,
children: artists
.asMap()
.entries
.map(
(artist) => LinkText(
(artist.key != artists.length - 1)
? "${artist.value.name}, "
: artist.value.name!,
"/artist/${artist.value.id}",
overflow: TextOverflow.ellipsis,
style: textStyle,
),
)
.toList(),
);
}
static Album simpleAlbum_X_Album(AlbumSimple albumSimple) {
Album album = Album();
album.albumType = albumSimple.albumType;
album.artists = albumSimple.artists;
album.availableMarkets = albumSimple.availableMarkets;
album.externalUrls = albumSimple.externalUrls;
album.href = albumSimple.href;
album.id = albumSimple.id;
album.images = albumSimple.images;
album.name = albumSimple.name;
album.releaseDate = albumSimple.releaseDate;
album.releaseDatePrecision = albumSimple.releaseDatePrecision;
album.tracks = albumSimple.tracks;
album.type = albumSimple.type;
album.uri = albumSimple.uri;
return album;
}
static Track simpleTrack_X_Track(TrackSimple trackSmp, AlbumSimple album) {
Track track = Track();
track.name = trackSmp.name;
track.album = album;
track.artists = trackSmp.artists;
track.availableMarkets = trackSmp.availableMarkets;
track.discNumber = trackSmp.discNumber;
track.durationMs = trackSmp.durationMs;
track.explicit = trackSmp.explicit;
track.externalUrls = trackSmp.externalUrls;
track.href = trackSmp.href;
track.id = trackSmp.id;
track.isPlayable = trackSmp.isPlayable;
track.linkedFrom = trackSmp.linkedFrom;
track.name = trackSmp.name;
track.previewUrl = trackSmp.previewUrl;
track.trackNumber = trackSmp.trackNumber;
track.type = trackSmp.type;
track.uri = trackSmp.uri;
return track;
}
}