mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45: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_hooks/flutter_hooks.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/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:scroll_to_index/scroll_to_index.dart';
|
||||
|
||||
class SyncedLyrics extends HookConsumerWidget {
|
||||
const SyncedLyrics({Key? key}) : super(key: key);
|
||||
@ -12,18 +16,13 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
Playback playback = ref.watch(playbackProvider);
|
||||
AudioPlayer player = ref.watch(audioPlayerProvider);
|
||||
final breakpoint = useBreakpoints();
|
||||
final controller = useAutoScrollController();
|
||||
final timedLyrics = useMemoized(() {
|
||||
if (playback.currentTrack == null) return null;
|
||||
return getTimedLyrics(playback.currentTrack!);
|
||||
}, [playback.currentTrack]);
|
||||
final lyricsSnapshot = useFuture(timedLyrics);
|
||||
final stream = useStream(
|
||||
player.positionStream.isBroadcast
|
||||
? player.positionStream
|
||||
: player.positionStream.asBroadcastStream(),
|
||||
);
|
||||
|
||||
final lyricsMap = useMemoized(
|
||||
() =>
|
||||
lyricsSnapshot.data?.lyrics
|
||||
@ -33,22 +32,65 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
[lyricsSnapshot.data],
|
||||
);
|
||||
|
||||
print(lyricsSnapshot.data?.name);
|
||||
final currentTime = useSyncedLyrics(ref, lyricsMap);
|
||||
|
||||
final currentLyric = useState("");
|
||||
|
||||
useEffect(() {
|
||||
if (stream.hasData && lyricsMap.containsKey(stream.data!.inSeconds)) {
|
||||
currentLyric.value = lyricsMap[stream.data!.inSeconds]!;
|
||||
}
|
||||
return null;
|
||||
}, [stream.data, stream.hasData]);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text("Lyrics"),
|
||||
Text(currentLyric.value),
|
||||
Center(
|
||||
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();
|
||||
}
|
||||
|
||||
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(", ")}"
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(" *\\([^)]*\\) *"), '')
|
||||
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
|
||||
.replaceAll(RegExp("feat.|ft."), '')
|
||||
.replaceAll(RegExp("\\s+"), ' ')
|
||||
@ -66,9 +85,8 @@ Future<List?> searchSong(
|
||||
apiKey = getRandomElement(lyricsSecrets);
|
||||
}
|
||||
const searchUrl = 'https://api.genius.com/search?q=';
|
||||
String song = optimizeQuery
|
||||
? getTitle(clearArtistsOfTitle(title, artist), artist)
|
||||
: "$title $artist";
|
||||
String song =
|
||||
optimizeQuery ? getTitle(title, artists: artist) : "$title $artist";
|
||||
|
||||
String reqUrl = "$searchUrl${Uri.encodeComponent(song)}";
|
||||
Map<String, String> headers = {"Authorization": 'Bearer $apiKey'};
|
||||
|
@ -16,7 +16,11 @@ Future<Track> toYoutubeTrack(
|
||||
final mainArtist = artistsName.first;
|
||||
final featuredArtists =
|
||||
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");
|
||||
final queryString = format
|
||||
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
||||
|
@ -32,8 +32,8 @@ Future<SubtitleSimple?> getTimedLyrics(Track track) async {
|
||||
final artistNames =
|
||||
track.artists?.map((artist) => artist.name!).toList() ?? [];
|
||||
final query = getTitle(
|
||||
clearArtistsOfTitle(track.name!, artistNames),
|
||||
artistNames,
|
||||
track.name!,
|
||||
artists: artistNames,
|
||||
);
|
||||
final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace(
|
||||
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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -59,6 +59,7 @@ dependencies:
|
||||
logger: ^1.1.0
|
||||
permission_handler: ^9.2.0
|
||||
marquee: ^2.2.1
|
||||
scroll_to_index: ^2.1.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Loading…
Reference in New Issue
Block a user