mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 15:35:17 +00:00
Save Track button support (incomplete)
Featured Playlist support
This commit is contained in:
parent
96629f6a83
commit
f3ecb24d3b
@ -24,6 +24,7 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
file_names: false
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
@ -7,7 +7,12 @@ import 'package:spotube/provider/SpotifyDI.dart';
|
||||
|
||||
class CategoryCard extends StatefulWidget {
|
||||
final Category category;
|
||||
const CategoryCard(this.category, {Key? key}) : super(key: key);
|
||||
final Iterable<PlaylistSimple>? playlists;
|
||||
const CategoryCard(
|
||||
this.category, {
|
||||
Key? key,
|
||||
this.playlists,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_CategoryCardState createState() => _CategoryCardState();
|
||||
@ -35,6 +40,7 @@ class _CategoryCardState extends State<CategoryCard> {
|
||||
return PlaylistGenreView(
|
||||
widget.category.id!,
|
||||
widget.category.name!,
|
||||
playlists: widget.playlists,
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -46,25 +52,34 @@ class _CategoryCardState extends State<CategoryCard> {
|
||||
),
|
||||
),
|
||||
Consumer<SpotifyDI>(
|
||||
builder: (context, data, child) =>
|
||||
FutureBuilder<Page<PlaylistSimple>>(
|
||||
future: data.spotifyApi.playlists
|
||||
.getByCategoryId(widget.category.id!)
|
||||
.getPage(4, 0),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return const Center(child: Text("Error occurred"));
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: Text("Loading.."));
|
||||
}
|
||||
return Wrap(
|
||||
spacing: 20,
|
||||
children: snapshot.data!.items!
|
||||
.map((playlist) => PlaylistCard(playlist))
|
||||
.toList(),
|
||||
builder: (context, data, child) {
|
||||
return FutureBuilder<Iterable<PlaylistSimple>>(
|
||||
future: widget.playlists == null
|
||||
? (widget.category.id != "user-featured-playlists"
|
||||
? data.spotifyApi.playlists
|
||||
.getByCategoryId(widget.category.id!)
|
||||
: data.spotifyApi.playlists.featured)
|
||||
.getPage(4, 0)
|
||||
.then((value) => value.items ?? [])
|
||||
: Future.value(widget.playlists),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return const Center(child: Text("Error occurred"));
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}),
|
||||
}
|
||||
return Wrap(
|
||||
spacing: 20,
|
||||
runSpacing: 20,
|
||||
children: snapshot.data!
|
||||
.map((playlist) => PlaylistCard(playlist))
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
|
@ -83,11 +83,18 @@ class _HomeState extends State<Home> {
|
||||
.list(country: "US")
|
||||
.getPage(15, pageKey);
|
||||
|
||||
var items = categories.items!.toList();
|
||||
if (pageKey == 0) {
|
||||
Category category = Category();
|
||||
category.id = "user-featured-playlists";
|
||||
category.name = "Featured";
|
||||
items.insert(0, category);
|
||||
}
|
||||
|
||||
if (categories.isLast && categories.items != null) {
|
||||
_pagingController.appendLastPage(categories.items!.toList());
|
||||
_pagingController.appendLastPage(items);
|
||||
} else if (categories.items != null) {
|
||||
_pagingController.appendPage(
|
||||
categories.items!.toList(), categories.nextOffset);
|
||||
_pagingController.appendPage(items, categories.nextOffset);
|
||||
}
|
||||
} catch (e) {
|
||||
_pagingController.error = e;
|
||||
@ -119,7 +126,6 @@ class _HomeState extends State<Home> {
|
||||
child: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
backgroundColor: Colors.blueGrey[50],
|
||||
destinations: sidebarTileList
|
||||
.map((e) => NavigationRailDestination(
|
||||
icon: Icon(e.icon),
|
||||
|
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:mpv_dart/mpv_dart.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/provider/SpotifyDI.dart';
|
||||
|
||||
class Player extends StatefulWidget {
|
||||
const Player({Key? key}) : super(key: key);
|
||||
@ -220,9 +221,32 @@ class _PlayerState extends State<Player> {
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.favorite_outline_rounded),
|
||||
onPressed: () {}),
|
||||
Consumer<SpotifyDI>(builder: (context, data, widget) {
|
||||
return FutureBuilder<bool>(
|
||||
future: playback.currentTrack?.id != null
|
||||
? data.spotifyApi.tracks.me
|
||||
.containsOne(playback.currentTrack!.id!)
|
||||
: Future.value(false),
|
||||
initialData: false,
|
||||
builder: (context, snapshot) {
|
||||
bool isLiked = snapshot.data ?? false;
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
!isLiked
|
||||
? Icons.favorite_outline_rounded
|
||||
: Icons.favorite_rounded,
|
||||
color: isLiked ? Colors.green : null,
|
||||
),
|
||||
onPressed: () {
|
||||
if (!isLiked &&
|
||||
playback.currentTrack?.id != null) {
|
||||
data.spotifyApi.tracks.me
|
||||
.saveOne(playback.currentTrack!.id!)
|
||||
.then((value) => setState(() {}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: Slider.adaptive(
|
||||
|
@ -7,8 +7,13 @@ import 'package:spotube/provider/SpotifyDI.dart';
|
||||
class PlaylistGenreView extends StatefulWidget {
|
||||
final String genreId;
|
||||
final String genreName;
|
||||
const PlaylistGenreView(this.genreId, this.genreName, {Key? key})
|
||||
: super(key: key);
|
||||
final Iterable<PlaylistSimple>? playlists;
|
||||
const PlaylistGenreView(
|
||||
this.genreId,
|
||||
this.genreName, {
|
||||
this.playlists,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
@override
|
||||
_PlaylistGenreViewState createState() => _PlaylistGenreViewState();
|
||||
}
|
||||
@ -37,9 +42,13 @@ class _PlaylistGenreViewState extends State<PlaylistGenreView> {
|
||||
builder: (context, data, child) => Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: FutureBuilder<Iterable<PlaylistSimple>>(
|
||||
future: data.spotifyApi.playlists
|
||||
.getByCategoryId(widget.genreId)
|
||||
.all(),
|
||||
future: widget.playlists == null
|
||||
? (widget.genreId != "user-featured-playlists"
|
||||
? data.spotifyApi.playlists
|
||||
.getByCategoryId(widget.genreId)
|
||||
.all()
|
||||
: data.spotifyApi.playlists.featured.all())
|
||||
: Future.value(widget.playlists),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return const Center(child: Text("Error occurred"));
|
||||
|
@ -20,7 +20,9 @@ class _UserLibraryState extends State<UserLibrary> {
|
||||
future: data.spotifyApi.playlists.me.all(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const CircularProgressIndicator.adaptive();
|
||||
return const Expanded(
|
||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||
);
|
||||
}
|
||||
Image image = Image();
|
||||
image.height = 300;
|
||||
@ -40,8 +42,8 @@ class _UserLibraryState extends State<UserLibrary> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Wrap(
|
||||
spacing: 8.0, // gap between adjacent chips
|
||||
runSpacing: 8.0, // gap between lines
|
||||
spacing: 20, // gap between adjacent chips
|
||||
runSpacing: 20, // gap between lines
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
PlaylistCard(likedTracksPlaylist),
|
||||
|
@ -1,145 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:spotube/models/YoutubeTrack.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:spotube/models/YoutubeSearchResult.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
Future<List<YtSearchResult>> searchYoutube(String query,
|
||||
{int limit = 20}) async {
|
||||
try {
|
||||
if (query.trim().isEmpty) throw Exception("query can't be blank");
|
||||
Client client = Client();
|
||||
Uri url = Uri(
|
||||
scheme: "https",
|
||||
host: "www.youtube.com",
|
||||
path: "results",
|
||||
queryParameters: {
|
||||
"search_query": query,
|
||||
},
|
||||
);
|
||||
Response page = await client.get(url);
|
||||
return parseSearch(page.body, limit);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
List<YtSearchResult> parseSearch(String html, int limit) {
|
||||
List<YtSearchResult> results = [];
|
||||
List<dynamic> dataInfo = [];
|
||||
bool scrapped = false;
|
||||
try {
|
||||
var initDoc = html.split("var ytInitialData = ");
|
||||
String data = initDoc[1].split(";</script><script")[0];
|
||||
html = data;
|
||||
} catch (e) {
|
||||
print("[Error extracting ytInitialData]: $e");
|
||||
}
|
||||
|
||||
try {
|
||||
var decodedHtml = jsonDecode(html)["contents"]
|
||||
["twoColumnSearchResultsRenderer"]["primaryContents"]
|
||||
["sectionListRenderer"]["contents"]
|
||||
.first["itemSectionRenderer"]["contents"];
|
||||
dataInfo = decodedHtml;
|
||||
scrapped = true;
|
||||
} catch (e) {
|
||||
print("[Error accessing itemSectionRenderer.contents]: $e");
|
||||
}
|
||||
|
||||
if (!scrapped) {
|
||||
try {
|
||||
dataInfo = jsonDecode(html
|
||||
.split("{\"itemSectionRenderer\":")
|
||||
.last
|
||||
.split("},{\"continuationItemRenderer\":{")
|
||||
.first)["contents"];
|
||||
scrapped = true;
|
||||
} catch (err) {
|
||||
print(
|
||||
"[Error in try again <accessing itemSectionRenderer.contents>]: $err");
|
||||
}
|
||||
}
|
||||
// failure
|
||||
if (!scrapped) {
|
||||
return [];
|
||||
}
|
||||
for (var data in dataInfo) {
|
||||
try {
|
||||
YtSearchResult result;
|
||||
data = data["videoRenderer"];
|
||||
if (data == null) continue;
|
||||
result = YtSearchResult(
|
||||
id: data["videoId"],
|
||||
title: data["title"]["runs"].first["text"],
|
||||
duration: "unavailable",
|
||||
thumbnail: data["thumbnail"]["thumbnails"].last["url"],
|
||||
channel: YtChannel(
|
||||
id: data["ownerText"]["runs"].first["navigationEndpoint"]
|
||||
["browseEndpoint"]["browseId"],
|
||||
name: data["ownerText"]["runs"].first["text"],
|
||||
url: "https://www.youtube.com" +
|
||||
data["ownerText"]["runs"].first["navigationEndpoint"]
|
||||
["browseEndpoint"]["canonicalBaseUrl"],
|
||||
),
|
||||
uploadDate: "unavailable",
|
||||
viewCount: "unavailable",
|
||||
type: "video");
|
||||
results.add(result);
|
||||
} catch (e) {
|
||||
print("[Error in construction of result]: $e");
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<Track> findYtVariant(Track track) async {
|
||||
YoutubeExplode youtube = YoutubeExplode();
|
||||
double includePercentage(String src, List matches) {
|
||||
int count = 0;
|
||||
matches.forEach((match) => {
|
||||
if (src.contains(match.toString())) {count++}
|
||||
});
|
||||
return (count / matches.length) * 100;
|
||||
}
|
||||
|
||||
var artistsName = track.artists?.map((ar) => ar.name).toList() ?? [];
|
||||
String queryString =
|
||||
"${artistsName.first} - ${track.name}${artistsName.length > 1 ? " feat. ${artistsName.sublist(1).join(" ")}" : ""}";
|
||||
|
||||
SearchList videos = await youtube.search.getVideos(queryString);
|
||||
|
||||
List<YoutubeRelevantTrack> tracksWithRelevance =
|
||||
await Future.wait(videos.map((video) async {
|
||||
double matchPercentage = includePercentage(video.title, [
|
||||
track.name,
|
||||
...artistsName,
|
||||
]);
|
||||
|
||||
Channel channel = await youtube.channels.get(video.channelId);
|
||||
bool sameChannel = (artistsName.first != null
|
||||
? channel.title.contains(artistsName.first!)
|
||||
: false) ||
|
||||
(artistsName.first?.contains(channel.title) ?? false);
|
||||
return YoutubeRelevantTrack(
|
||||
url: video.url,
|
||||
matchPercentage: matchPercentage,
|
||||
sameChannel: sameChannel,
|
||||
id: video.id.value);
|
||||
}));
|
||||
|
||||
tracksWithRelevance.sort((a, b) {
|
||||
return a.matchPercentage.compareTo(b.matchPercentage);
|
||||
});
|
||||
|
||||
List<YoutubeRelevantTrack> sameChannelTracks =
|
||||
tracksWithRelevance.where((tr) => tr.sameChannel).toList();
|
||||
|
||||
track.uri = (sameChannelTracks.isNotEmpty
|
||||
? sameChannelTracks.first.url
|
||||
: tracksWithRelevance.isNotEmpty
|
||||
? tracksWithRelevance.first.url
|
||||
: videos.first.url);
|
||||
return track;
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -55,7 +55,7 @@ class MyApp extends StatelessWidget {
|
||||
],
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Flutter Demo',
|
||||
title: 'Spotube',
|
||||
theme: ThemeData(
|
||||
primaryColor: Colors.greenAccent[400],
|
||||
primarySwatch: Colors.green,
|
||||
@ -87,6 +87,9 @@ class MyApp extends StatelessWidget {
|
||||
color: Colors.grey[800]!,
|
||||
),
|
||||
),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: Colors.blueGrey[50],
|
||||
)),
|
||||
home: const Home(),
|
||||
),
|
||||
|
@ -1,72 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class YtSearchResult {
|
||||
String? id;
|
||||
String? title;
|
||||
String? duration;
|
||||
String? thumbnail;
|
||||
YtChannel? channel;
|
||||
String? uploadDate;
|
||||
String? viewCount;
|
||||
String? type;
|
||||
|
||||
YtSearchResult(
|
||||
{this.id,
|
||||
this.title,
|
||||
this.duration,
|
||||
this.thumbnail,
|
||||
this.channel,
|
||||
this.uploadDate,
|
||||
this.viewCount,
|
||||
this.type});
|
||||
|
||||
YtSearchResult.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
title = json['title'];
|
||||
duration = json['duration'];
|
||||
thumbnail = json['thumbnail'];
|
||||
channel = json['channel'] != null
|
||||
? new YtChannel.fromJson(json['channel'])
|
||||
: null;
|
||||
uploadDate = json['uploadDate'];
|
||||
viewCount = json['viewCount'];
|
||||
type = json['type'];
|
||||
}
|
||||
|
||||
String toJson() {
|
||||
final Map<String, dynamic> data = new Map<String, dynamic>();
|
||||
data['id'] = this.id;
|
||||
data['title'] = this.title;
|
||||
data['duration'] = this.duration;
|
||||
data['thumbnail'] = this.thumbnail;
|
||||
if (this.channel != null) {
|
||||
data['channel'] = this.channel?.toJson();
|
||||
}
|
||||
data['uploadDate'] = this.uploadDate;
|
||||
data['viewCount'] = this.viewCount;
|
||||
data['type'] = this.type;
|
||||
return jsonEncode(data);
|
||||
}
|
||||
}
|
||||
|
||||
class YtChannel {
|
||||
String? id;
|
||||
String? name;
|
||||
String? url;
|
||||
|
||||
YtChannel({this.id, this.name, this.url});
|
||||
|
||||
YtChannel.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
name = json['name'];
|
||||
url = json['url'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = new Map<String, dynamic>();
|
||||
data['id'] = this.id;
|
||||
data['name'] = this.name;
|
||||
data['url'] = this.url;
|
||||
return data;
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class YoutubeRelevantTrack {
|
||||
String url;
|
||||
double matchPercentage;
|
||||
bool sameChannel;
|
||||
String id;
|
||||
YoutubeRelevantTrack({
|
||||
required this.url,
|
||||
required this.matchPercentage,
|
||||
required this.sameChannel,
|
||||
required this.id,
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user