mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
SyncedLyrics autoscrolling active lyric implementation
improved title cleaning for youtube search
This commit is contained in:
parent
e92f107e55
commit
8af0281b23
@ -1,10 +1,14 @@
|
|||||||
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:just_audio/just_audio.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/helpers/artist-to-string.dart';
|
||||||
import 'package:spotube/helpers/timed-lyrics.dart';
|
import 'package:spotube/helpers/timed-lyrics.dart';
|
||||||
import 'package:spotube/provider/AudioPlayer.dart';
|
import 'package:spotube/hooks/useAutoScrollController.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
|
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';
|
||||||
|
|
||||||
class SyncedLyrics extends HookConsumerWidget {
|
class SyncedLyrics extends HookConsumerWidget {
|
||||||
const SyncedLyrics({Key? key}) : super(key: key);
|
const SyncedLyrics({Key? key}) : super(key: key);
|
||||||
@ -12,18 +16,13 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
Playback playback = ref.watch(playbackProvider);
|
||||||
AudioPlayer player = ref.watch(audioPlayerProvider);
|
final breakpoint = useBreakpoints();
|
||||||
|
final controller = useAutoScrollController();
|
||||||
final timedLyrics = useMemoized(() {
|
final timedLyrics = useMemoized(() {
|
||||||
if (playback.currentTrack == null) return null;
|
if (playback.currentTrack == null) return null;
|
||||||
return getTimedLyrics(playback.currentTrack!);
|
return getTimedLyrics(playback.currentTrack!);
|
||||||
}, [playback.currentTrack]);
|
}, [playback.currentTrack]);
|
||||||
final lyricsSnapshot = useFuture(timedLyrics);
|
final lyricsSnapshot = useFuture(timedLyrics);
|
||||||
final stream = useStream(
|
|
||||||
player.positionStream.isBroadcast
|
|
||||||
? player.positionStream
|
|
||||||
: player.positionStream.asBroadcastStream(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final lyricsMap = useMemoized(
|
final lyricsMap = useMemoized(
|
||||||
() =>
|
() =>
|
||||||
lyricsSnapshot.data?.lyrics
|
lyricsSnapshot.data?.lyrics
|
||||||
@ -33,22 +32,65 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
[lyricsSnapshot.data],
|
[lyricsSnapshot.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
print(lyricsSnapshot.data?.name);
|
final currentTime = useSyncedLyrics(ref, lyricsMap);
|
||||||
|
|
||||||
final currentLyric = useState("");
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (stream.hasData && lyricsMap.containsKey(stream.data!.inSeconds)) {
|
|
||||||
currentLyric.value = lyricsMap[stream.data!.inSeconds]!;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [stream.data, stream.hasData]);
|
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Text("Lyrics"),
|
Center(
|
||||||
Text(currentLyric.value),
|
child: Text(
|
||||||
|
playback.currentTrack?.name ?? "",
|
||||||
|
style: breakpoint >= Breakpoints.md
|
||||||
|
? textTheme.headline3
|
||||||
|
: textTheme.headline4?.copyWith(fontSize: 25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
artistsToString<Artist>(playback.currentTrack?.artists ?? []),
|
||||||
|
style: breakpoint >= Breakpoints.md
|
||||||
|
? textTheme.headline5
|
||||||
|
: textTheme.headline6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (lyricsSnapshot.hasData)
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: controller,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final lyricSlice = lyricsSnapshot.data!.lyrics[index];
|
||||||
|
final isActive = lyricSlice.time.inSeconds == currentTime;
|
||||||
|
if (isActive) {
|
||||||
|
controller.scrollToIndex(
|
||||||
|
index,
|
||||||
|
preferPosition: AutoScrollPosition.middle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AutoScrollTag(
|
||||||
|
key: ValueKey(index),
|
||||||
|
index: index,
|
||||||
|
controller: controller,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
lyricSlice.text,
|
||||||
|
style: TextStyle(
|
||||||
|
// indicating the active state of that lyric slice
|
||||||
|
color: isActive ? Colors.green : null,
|
||||||
|
fontWeight: isActive ? FontWeight.bold : null,
|
||||||
|
fontSize: 30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemCount: lyricsSnapshot.data!.lyrics.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -14,10 +14,29 @@ String clearArtistsOfTitle(String title, List<String> artists) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
String getTitle(String title, [List<String> artists = const []]) {
|
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(", ")}"
|
return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}"
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll(RegExp(" *\\([^)]*\\) *"), '')
|
|
||||||
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
|
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
|
||||||
.replaceAll(RegExp("feat.|ft."), '')
|
.replaceAll(RegExp("feat.|ft."), '')
|
||||||
.replaceAll(RegExp("\\s+"), ' ')
|
.replaceAll(RegExp("\\s+"), ' ')
|
||||||
@ -66,9 +85,8 @@ Future<List?> searchSong(
|
|||||||
apiKey = getRandomElement(lyricsSecrets);
|
apiKey = getRandomElement(lyricsSecrets);
|
||||||
}
|
}
|
||||||
const searchUrl = 'https://api.genius.com/search?q=';
|
const searchUrl = 'https://api.genius.com/search?q=';
|
||||||
String song = optimizeQuery
|
String song =
|
||||||
? getTitle(clearArtistsOfTitle(title, artist), artist)
|
optimizeQuery ? getTitle(title, artists: artist) : "$title $artist";
|
||||||
: "$title $artist";
|
|
||||||
|
|
||||||
String reqUrl = "$searchUrl${Uri.encodeComponent(song)}";
|
String reqUrl = "$searchUrl${Uri.encodeComponent(song)}";
|
||||||
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
|
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
|
||||||
|
@ -16,7 +16,11 @@ Future<Track> toYoutubeTrack(
|
|||||||
final mainArtist = artistsName.first;
|
final mainArtist = artistsName.first;
|
||||||
final featuredArtists =
|
final featuredArtists =
|
||||||
artistsName.length > 1 ? "feat. " + artistsName.sublist(1).join(" ") : "";
|
artistsName.length > 1 ? "feat. " + artistsName.sublist(1).join(" ") : "";
|
||||||
final title = getTitle(clearArtistsOfTitle(track.name!, artistsName)).trim();
|
final title = getTitle(
|
||||||
|
track.name!,
|
||||||
|
artists: artistsName,
|
||||||
|
onlyCleanArtist: true,
|
||||||
|
).trim();
|
||||||
logger.v("[Track Search Title] $title");
|
logger.v("[Track Search Title] $title");
|
||||||
final queryString = format
|
final queryString = format
|
||||||
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
||||||
|
@ -32,8 +32,8 @@ Future<SubtitleSimple?> getTimedLyrics(Track track) async {
|
|||||||
final artistNames =
|
final artistNames =
|
||||||
track.artists?.map((artist) => artist.name!).toList() ?? [];
|
track.artists?.map((artist) => artist.name!).toList() ?? [];
|
||||||
final query = getTitle(
|
final query = getTitle(
|
||||||
clearArtistsOfTitle(track.name!, artistNames),
|
track.name!,
|
||||||
artistNames,
|
artists: artistNames,
|
||||||
);
|
);
|
||||||
final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace(
|
final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace(
|
||||||
queryParameters: {"q": query},
|
queryParameters: {"q": query},
|
||||||
|
78
lib/hooks/useAutoScrollController.dart
Normal file
78
lib/hooks/useAutoScrollController.dart
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||||
|
|
||||||
|
/// Creates [AutoScrollController] that will be disposed automatically.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
/// - [AutoScrollController]
|
||||||
|
AutoScrollController useAutoScrollController({
|
||||||
|
double initialScrollOffset = 0.0,
|
||||||
|
bool keepScrollOffset = true,
|
||||||
|
String? debugLabel,
|
||||||
|
Axis? axis,
|
||||||
|
AutoScrollController? copyTagsFrom,
|
||||||
|
double? suggestedRowHeight,
|
||||||
|
Rect Function() viewportBoundaryGetter = defaultViewportBoundaryGetter,
|
||||||
|
List<Object?>? keys,
|
||||||
|
}) {
|
||||||
|
return use(
|
||||||
|
_AutoScrollControllerHook(
|
||||||
|
initialScrollOffset: initialScrollOffset,
|
||||||
|
keepScrollOffset: keepScrollOffset,
|
||||||
|
debugLabel: debugLabel,
|
||||||
|
axis: axis,
|
||||||
|
copyTagsFrom: copyTagsFrom,
|
||||||
|
suggestedRowHeight: suggestedRowHeight,
|
||||||
|
viewportBoundaryGetter: viewportBoundaryGetter,
|
||||||
|
keys: keys,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutoScrollControllerHook extends Hook<AutoScrollController> {
|
||||||
|
const _AutoScrollControllerHook({
|
||||||
|
required this.initialScrollOffset,
|
||||||
|
required this.keepScrollOffset,
|
||||||
|
required this.viewportBoundaryGetter,
|
||||||
|
this.axis,
|
||||||
|
this.copyTagsFrom,
|
||||||
|
this.suggestedRowHeight,
|
||||||
|
this.debugLabel,
|
||||||
|
List<Object?>? keys,
|
||||||
|
}) : super(keys: keys);
|
||||||
|
|
||||||
|
final double initialScrollOffset;
|
||||||
|
final bool keepScrollOffset;
|
||||||
|
final String? debugLabel;
|
||||||
|
final Axis? axis;
|
||||||
|
final AutoScrollController? copyTagsFrom;
|
||||||
|
final double? suggestedRowHeight;
|
||||||
|
final Rect Function() viewportBoundaryGetter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
HookState<AutoScrollController, Hook<AutoScrollController>> createState() =>
|
||||||
|
_AutoScrollControllerHookState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutoScrollControllerHookState
|
||||||
|
extends HookState<AutoScrollController, _AutoScrollControllerHook> {
|
||||||
|
late final controller = AutoScrollController(
|
||||||
|
initialScrollOffset: hook.initialScrollOffset,
|
||||||
|
keepScrollOffset: hook.keepScrollOffset,
|
||||||
|
debugLabel: hook.debugLabel,
|
||||||
|
axis: hook.axis,
|
||||||
|
copyTagsFrom: hook.copyTagsFrom,
|
||||||
|
suggestedRowHeight: hook.suggestedRowHeight,
|
||||||
|
viewportBoundaryGetter: hook.viewportBoundaryGetter,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AutoScrollController build(BuildContext context) => controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => controller.dispose();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugLabel => 'useAutoScrollController';
|
||||||
|
}
|
23
lib/hooks/useSyncedLyrics.dart
Normal file
23
lib/hooks/useSyncedLyrics.dart
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotube/provider/AudioPlayer.dart';
|
||||||
|
|
||||||
|
useSyncedLyrics(WidgetRef ref, Map<int, String> lyricsMap) {
|
||||||
|
final player = ref.watch(audioPlayerProvider);
|
||||||
|
final stream = player.positionStream.isBroadcast
|
||||||
|
? player.positionStream
|
||||||
|
: player.positionStream.asBroadcastStream();
|
||||||
|
|
||||||
|
final currentTime = useState(0);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final lol = stream.listen((pos) {
|
||||||
|
if (lyricsMap.containsKey(pos.inSeconds)) {
|
||||||
|
currentTime.value = pos.inSeconds;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => lol.cancel();
|
||||||
|
}, [lyricsMap]);
|
||||||
|
|
||||||
|
return currentTime.value;
|
||||||
|
}
|
@ -597,6 +597,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.27.3"
|
version: "0.27.3"
|
||||||
|
scroll_to_index:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: scroll_to_index
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -59,6 +59,7 @@ dependencies:
|
|||||||
logger: ^1.1.0
|
logger: ^1.1.0
|
||||||
permission_handler: ^9.2.0
|
permission_handler: ^9.2.0
|
||||||
marquee: ^2.2.1
|
marquee: ^2.2.1
|
||||||
|
scroll_to_index: ^2.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Loading…
Reference in New Issue
Block a user