Skip to content

Commit 9095a8c

Browse files
committed
feat: add songlink based track matching for youtube and open song link button
songlink.com will provide accurate match verified by community for most spotify tracks improving overall match accuracy for Youtube audio source
1 parent 6f71e52 commit 9095a8c

File tree

14 files changed

+565
-19
lines changed

14 files changed

+565
-19
lines changed

assets/logos/songlink-transparent.png

71.5 KB
Loading

assets/logos/songlink.png

86.5 KB
Loading

lib/collections/assets.gen.dart

+16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/components/player/player.dart

+16
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import 'package:spotube/pages/lyrics/lyrics.dart';
2525
import 'package:spotube/provider/authentication_provider.dart';
2626
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
2727
import 'package:spotube/utils/type_conversion_utils.dart';
28+
import 'package:url_launcher/url_launcher_string.dart';
2829

2930
class PlayerView extends HookConsumerWidget {
3031
final PanelController panelController;
@@ -137,6 +138,21 @@ class PlayerView extends HookConsumerWidget {
137138
onPressed: panelController.close,
138139
),
139140
actions: [
141+
IconButton(
142+
icon: Assets.logos.songlink.image(
143+
width: 20,
144+
height: 20,
145+
),
146+
tooltip: context.l10n.song_link,
147+
onPressed: currentTrack == null
148+
? null
149+
: () {
150+
final url =
151+
"https://song.link/s/${currentTrack.id}";
152+
153+
launchUrlString(url);
154+
},
155+
),
140156
IconButton(
141157
icon: const Icon(SpotubeIcons.info, size: 18),
142158
tooltip: context.l10n.details,

lib/components/shared/track_tile/track_options.dart

+17
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
77
import 'package:go_router/go_router.dart';
88
import 'package:hooks_riverpod/hooks_riverpod.dart';
99
import 'package:spotify/spotify.dart';
10+
import 'package:spotube/collections/assets.gen.dart';
1011
import 'package:spotube/collections/spotube_icons.dart';
1112
import 'package:spotube/components/library/user_local_tracks.dart';
1213
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
@@ -26,10 +27,12 @@ import 'package:spotube/provider/spotify_provider.dart';
2627
import 'package:spotube/services/mutations/mutations.dart';
2728
import 'package:spotube/services/queries/search.dart';
2829
import 'package:spotube/utils/type_conversion_utils.dart';
30+
import 'package:url_launcher/url_launcher_string.dart';
2931

3032
enum TrackOptionValue {
3133
album,
3234
share,
35+
songlink,
3336
addToPlaylist,
3437
addToQueue,
3538
removeFromPlaylist,
@@ -165,6 +168,7 @@ class TrackOptions extends HookConsumerWidget {
165168
final scaffoldMessenger = ScaffoldMessenger.of(context);
166169
final mediaQuery = MediaQuery.of(context);
167170
final router = GoRouter.of(context);
171+
final ThemeData(:colorScheme) = Theme.of(context);
168172

169173
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
170174
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
@@ -276,6 +280,10 @@ class TrackOptions extends HookConsumerWidget {
276280
case TrackOptionValue.share:
277281
actionShare(context, track);
278282
break;
283+
case TrackOptionValue.songlink:
284+
final url = "https://song.link/s/${track.id}";
285+
await launchUrlString(url);
286+
break;
279287
case TrackOptionValue.details:
280288
showDialog(
281289
context: context,
@@ -418,6 +426,15 @@ class TrackOptions extends HookConsumerWidget {
418426
leading: const Icon(SpotubeIcons.share),
419427
title: Text(context.l10n.share),
420428
),
429+
PopSheetEntry(
430+
value: TrackOptionValue.songlink,
431+
leading: Assets.logos.songlinkTransparent.image(
432+
width: 22,
433+
height: 22,
434+
color: colorScheme.onSurface.withOpacity(0.5),
435+
),
436+
title: Text(context.l10n.song_link),
437+
),
421438
PopSheetEntry(
422439
value: TrackOptionValue.details,
423440
leading: const Icon(SpotubeIcons.info),

lib/l10n/app_en.arb

+2-1
Original file line numberDiff line numberDiff line change
@@ -294,5 +294,6 @@
294294
"endless_playback": "Endless Playback",
295295
"delete_playlist": "Delete Playlist",
296296
"delete_playlist_confirmation": "Are you sure you want to delete this playlist?",
297-
"local_tracks": "Local Tracks"
297+
"local_tracks": "Local Tracks",
298+
"song_link": "Song Link"
298299
}

lib/services/song_link/model.dart

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
part of './song_link.dart';
2+
3+
@freezed
4+
class SongLink with _$SongLink {
5+
const factory SongLink({
6+
required String displayName,
7+
required String linkId,
8+
required String platform,
9+
required bool show,
10+
required String? uniqueId,
11+
required String? country,
12+
required String? url,
13+
required String? nativeAppUriMobile,
14+
required String? nativeAppUriDesktop,
15+
}) = _SongLink;
16+
17+
factory SongLink.fromJson(Map<String, dynamic> json) =>
18+
_$SongLinkFromJson(json);
19+
}

lib/services/song_link/song_link.dart

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
library song_link;
2+
3+
import 'dart:convert';
4+
5+
import 'package:dio/dio.dart';
6+
import 'package:freezed_annotation/freezed_annotation.dart';
7+
import 'package:html/parser.dart';
8+
9+
part 'model.dart';
10+
11+
part 'song_link.freezed.dart';
12+
part 'song_link.g.dart';
13+
14+
abstract class SongLinkService {
15+
static Future<List<SongLink>> links(String spotifyId) async {
16+
final dio = Dio();
17+
18+
final res = await dio.get(
19+
"https://song.link/s/$spotifyId",
20+
options: Options(
21+
headers: {
22+
"Accept":
23+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
24+
"User-Agent":
25+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
26+
},
27+
responseType: ResponseType.plain,
28+
),
29+
);
30+
31+
final document = parse(res.data);
32+
33+
final script = document.getElementById("__NEXT_DATA__")?.text;
34+
35+
if (script == null) {
36+
return <SongLink>[];
37+
}
38+
39+
final pageProps = jsonDecode(script) as Map<String, dynamic>;
40+
final songLinks =
41+
pageProps["props"]["pageProps"]["pageData"]["sections"].firstWhere(
42+
(section) => section["sectionId"] == "section|auto|links|listen",
43+
)["links"] as List;
44+
45+
return songLinks.map((link) => SongLink.fromJson(link)).toList();
46+
}
47+
}

0 commit comments

Comments
 (0)