mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
chore: remove unnecessary files and youtube_explode
This commit is contained in:
parent
962d9118dd
commit
5a4e3baa51
@ -1,101 +0,0 @@
|
|||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|
||||||
|
|
||||||
part 'cache_track.g.dart';
|
|
||||||
|
|
||||||
@HiveType(typeId: 2)
|
|
||||||
class CacheTrackEngagement {
|
|
||||||
@HiveField(0)
|
|
||||||
late int viewCount;
|
|
||||||
|
|
||||||
@HiveField(1)
|
|
||||||
late int? likeCount;
|
|
||||||
|
|
||||||
@HiveField(2)
|
|
||||||
late int? dislikeCount;
|
|
||||||
|
|
||||||
CacheTrackEngagement();
|
|
||||||
|
|
||||||
CacheTrackEngagement.fromEngagement(Engagement engagement)
|
|
||||||
: viewCount = engagement.viewCount,
|
|
||||||
likeCount = engagement.likeCount,
|
|
||||||
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)
|
|
||||||
class CacheTrack extends HiveObject {
|
|
||||||
@HiveField(0)
|
|
||||||
late String id;
|
|
||||||
|
|
||||||
@HiveField(1)
|
|
||||||
late String title;
|
|
||||||
|
|
||||||
@HiveField(2)
|
|
||||||
late String channelId;
|
|
||||||
|
|
||||||
@HiveField(3)
|
|
||||||
late String? uploadDate;
|
|
||||||
|
|
||||||
@HiveField(4)
|
|
||||||
late String? publishDate;
|
|
||||||
|
|
||||||
@HiveField(5)
|
|
||||||
late String description;
|
|
||||||
|
|
||||||
@HiveField(6)
|
|
||||||
late String? duration;
|
|
||||||
|
|
||||||
@HiveField(7)
|
|
||||||
late List<String>? keywords;
|
|
||||||
|
|
||||||
@HiveField(8)
|
|
||||||
late CacheTrackEngagement engagement;
|
|
||||||
|
|
||||||
@HiveField(9)
|
|
||||||
late String mode;
|
|
||||||
|
|
||||||
@HiveField(10)
|
|
||||||
late String author;
|
|
||||||
|
|
||||||
@HiveField(11)
|
|
||||||
late List<CacheTrackSkipSegment>? skipSegments;
|
|
||||||
|
|
||||||
CacheTrack();
|
|
||||||
|
|
||||||
CacheTrack.fromVideo(
|
|
||||||
Video video,
|
|
||||||
this.mode, {
|
|
||||||
required List<Map<String, int>> skipSegments,
|
|
||||||
}) : id = video.id.value,
|
|
||||||
title = video.title,
|
|
||||||
author = video.author,
|
|
||||||
channelId = video.channelId.value,
|
|
||||||
uploadDate = video.uploadDate.toString(),
|
|
||||||
publishDate = video.publishDate.toString(),
|
|
||||||
description = video.description,
|
|
||||||
duration = video.duration.toString(),
|
|
||||||
keywords = video.keywords,
|
|
||||||
engagement = CacheTrackEngagement.fromEngagement(video.engagement),
|
|
||||||
skipSegments = skipSegments
|
|
||||||
.map((segment) => CacheTrackSkipSegment.fromJson(segment))
|
|
||||||
.toList();
|
|
||||||
}
|
|
@ -1,148 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'cache_track.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// TypeAdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
class CacheTrackEngagementAdapter extends TypeAdapter<CacheTrackEngagement> {
|
|
||||||
@override
|
|
||||||
final int typeId = 2;
|
|
||||||
|
|
||||||
@override
|
|
||||||
CacheTrackEngagement read(BinaryReader reader) {
|
|
||||||
final numOfFields = reader.readByte();
|
|
||||||
final fields = <int, dynamic>{
|
|
||||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
|
||||||
};
|
|
||||||
return CacheTrackEngagement()
|
|
||||||
..viewCount = fields[0] as int
|
|
||||||
..likeCount = fields[1] as int?
|
|
||||||
..dislikeCount = fields[2] as int?;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void write(BinaryWriter writer, CacheTrackEngagement obj) {
|
|
||||||
writer
|
|
||||||
..writeByte(3)
|
|
||||||
..writeByte(0)
|
|
||||||
..write(obj.viewCount)
|
|
||||||
..writeByte(1)
|
|
||||||
..write(obj.likeCount)
|
|
||||||
..writeByte(2)
|
|
||||||
..write(obj.dislikeCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => typeId.hashCode;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
other is CacheTrackEngagementAdapter &&
|
|
||||||
runtimeType == other.runtimeType &&
|
|
||||||
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> {
|
|
||||||
@override
|
|
||||||
final int typeId = 1;
|
|
||||||
|
|
||||||
@override
|
|
||||||
CacheTrack read(BinaryReader reader) {
|
|
||||||
final numOfFields = reader.readByte();
|
|
||||||
final fields = <int, dynamic>{
|
|
||||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
|
||||||
};
|
|
||||||
return CacheTrack()
|
|
||||||
..id = fields[0] as String
|
|
||||||
..title = fields[1] as String
|
|
||||||
..channelId = fields[2] as String
|
|
||||||
..uploadDate = fields[3] as String?
|
|
||||||
..publishDate = fields[4] as String?
|
|
||||||
..description = fields[5] as String
|
|
||||||
..duration = fields[6] as String?
|
|
||||||
..keywords = (fields[7] as List?)?.cast<String>()
|
|
||||||
..engagement = fields[8] as CacheTrackEngagement
|
|
||||||
..mode = fields[9] as String
|
|
||||||
..author = fields[10] as String
|
|
||||||
..skipSegments = (fields[11] as List?)?.cast<CacheTrackSkipSegment>();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void write(BinaryWriter writer, CacheTrack obj) {
|
|
||||||
writer
|
|
||||||
..writeByte(12)
|
|
||||||
..writeByte(0)
|
|
||||||
..write(obj.id)
|
|
||||||
..writeByte(1)
|
|
||||||
..write(obj.title)
|
|
||||||
..writeByte(2)
|
|
||||||
..write(obj.channelId)
|
|
||||||
..writeByte(3)
|
|
||||||
..write(obj.uploadDate)
|
|
||||||
..writeByte(4)
|
|
||||||
..write(obj.publishDate)
|
|
||||||
..writeByte(5)
|
|
||||||
..write(obj.description)
|
|
||||||
..writeByte(6)
|
|
||||||
..write(obj.duration)
|
|
||||||
..writeByte(7)
|
|
||||||
..write(obj.keywords)
|
|
||||||
..writeByte(8)
|
|
||||||
..write(obj.engagement)
|
|
||||||
..writeByte(9)
|
|
||||||
..write(obj.mode)
|
|
||||||
..writeByte(10)
|
|
||||||
..write(obj.author)
|
|
||||||
..writeByte(11)
|
|
||||||
..write(obj.skipSegments);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => typeId.hashCode;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
other is CacheTrackAdapter &&
|
|
||||||
runtimeType == other.runtimeType &&
|
|
||||||
typeId == other.typeId;
|
|
||||||
}
|
|
@ -1,37 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:catcher/catcher.dart';
|
|
||||||
import 'package:http/http.dart';
|
|
||||||
import 'package:piped_client/piped_client.dart';
|
import 'package:piped_client/piped_client.dart';
|
||||||
import 'package:spotube/entities/cache_track.dart';
|
|
||||||
import 'package:spotube/models/logger.dart';
|
|
||||||
import 'package:spotube/models/track.dart';
|
import 'package:spotube/models/track.dart';
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
|
||||||
import 'package:spotube/services/youtube.dart';
|
import 'package:spotube/services/youtube.dart';
|
||||||
import 'package:spotube/utils/duration.dart';
|
|
||||||
|
|
||||||
extension PipedSearchItemExtension on PipedSearchItem {
|
|
||||||
static PipedSearchItemStream fromCacheTrack(CacheTrack cacheTrack) {
|
|
||||||
return PipedSearchItemStream(
|
|
||||||
type: PipedSearchItemType.stream,
|
|
||||||
url: "watch?v=${cacheTrack.id}",
|
|
||||||
title: cacheTrack.title,
|
|
||||||
uploaderName: cacheTrack.author,
|
|
||||||
uploaderUrl: "/channel/${cacheTrack.channelId}",
|
|
||||||
uploaded: -1,
|
|
||||||
uploadedDate: cacheTrack.uploadDate ?? "",
|
|
||||||
shortDescription: cacheTrack.description,
|
|
||||||
isShort: false,
|
|
||||||
thumbnail: "",
|
|
||||||
duration: cacheTrack.duration != null
|
|
||||||
? tryParseDuration(cacheTrack.duration!) ?? Duration.zero
|
|
||||||
: Duration.zero,
|
|
||||||
uploaderAvatar: "",
|
|
||||||
uploaderVerified: false,
|
|
||||||
views: cacheTrack.engagement.viewCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PipedStreamResponseExtension on PipedStreamResponse {
|
extension PipedStreamResponseExtension on PipedStreamResponse {
|
||||||
static Future<PipedStreamResponse> fromBackendTrack(
|
static Future<PipedStreamResponse> fromBackendTrack(
|
||||||
|
@ -1,159 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:catcher/catcher.dart';
|
|
||||||
import 'package:http/http.dart';
|
|
||||||
import 'package:spotube/entities/cache_track.dart';
|
|
||||||
import 'package:spotube/models/logger.dart';
|
|
||||||
import 'package:spotube/models/track.dart';
|
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
|
||||||
import 'package:spotube/utils/duration.dart';
|
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|
||||||
|
|
||||||
extension VideoFromCacheTrackExtension on Video {
|
|
||||||
static Video fromCacheTrack(CacheTrack cacheTrack) {
|
|
||||||
return Video(
|
|
||||||
VideoId.fromString(cacheTrack.id),
|
|
||||||
cacheTrack.title,
|
|
||||||
cacheTrack.author,
|
|
||||||
ChannelId.fromString(cacheTrack.channelId),
|
|
||||||
cacheTrack.uploadDate != null
|
|
||||||
? DateTime.tryParse(cacheTrack.uploadDate!)
|
|
||||||
: null,
|
|
||||||
cacheTrack.uploadDate,
|
|
||||||
cacheTrack.publishDate != null
|
|
||||||
? DateTime.tryParse(cacheTrack.publishDate!)
|
|
||||||
: null,
|
|
||||||
cacheTrack.description,
|
|
||||||
cacheTrack.duration != null
|
|
||||||
? tryParseDuration(cacheTrack.duration!)
|
|
||||||
: null,
|
|
||||||
ThumbnailSet(cacheTrack.id),
|
|
||||||
cacheTrack.keywords,
|
|
||||||
Engagement(
|
|
||||||
cacheTrack.engagement.viewCount,
|
|
||||||
cacheTrack.engagement.likeCount,
|
|
||||||
cacheTrack.engagement.dislikeCount,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<Video> fromBackendTrack(
|
|
||||||
BackendTrack track, YoutubeExplode youtube) {
|
|
||||||
return youtube.videos.get(VideoId.fromString(track.youtubeId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ThumbnailSetJson on ThumbnailSet {
|
|
||||||
static ThumbnailSet fromJson(Map<String, dynamic> map) {
|
|
||||||
return ThumbnailSet(map["videoId"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
"videoId": videoId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EngagementJson on Engagement {
|
|
||||||
static Engagement fromJson(Map<String, dynamic> map) {
|
|
||||||
return Engagement(
|
|
||||||
map["viewCount"],
|
|
||||||
map["likeCount"],
|
|
||||||
map["dislikeCount"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
"dislikeCount": dislikeCount,
|
|
||||||
"likeCount": likeCount,
|
|
||||||
"viewCount": viewCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VideoToJson on Video {
|
|
||||||
static Video fromJson(Map<String, dynamic> map) {
|
|
||||||
return Video(
|
|
||||||
VideoId(map["id"]),
|
|
||||||
map["title"],
|
|
||||||
map["author"],
|
|
||||||
ChannelId(map["channelId"]),
|
|
||||||
DateTime.tryParse(map["uploadDate"]),
|
|
||||||
map["uploadDate"],
|
|
||||||
DateTime.tryParse(map["publishDate"]),
|
|
||||||
map["description"],
|
|
||||||
parseDuration(map["duration"]),
|
|
||||||
ThumbnailSetJson.fromJson(map["thumbnails"]),
|
|
||||||
List.castFrom<dynamic, String>(map["keywords"]),
|
|
||||||
EngagementJson.fromJson(map["engagement"]),
|
|
||||||
map["isLive"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
"hasWatchPage": hasWatchPage,
|
|
||||||
"url": url,
|
|
||||||
"author": author,
|
|
||||||
"channelId": channelId.value,
|
|
||||||
"description": description,
|
|
||||||
"duration": duration.toString(),
|
|
||||||
"engagement": engagement.toJson(),
|
|
||||||
"id": id.value,
|
|
||||||
"isLive": isLive,
|
|
||||||
"keywords": keywords.toList(),
|
|
||||||
"publishDate": publishDate.toString(),
|
|
||||||
"thumbnails": thumbnails.toJson(),
|
|
||||||
"title": title,
|
|
||||||
"uploadDate": uploadDate.toString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GetSkipSegments on Video {
|
|
||||||
Future<List<Map<String, int>>> getSkipSegments(
|
|
||||||
UserPreferences preferences) async {
|
|
||||||
if (!preferences.skipSponsorSegments) return [];
|
|
||||||
try {
|
|
||||||
final res = await get(Uri(
|
|
||||||
scheme: "https",
|
|
||||||
host: "sponsor.ajay.app",
|
|
||||||
path: "/api/skipSegments",
|
|
||||||
queryParameters: {
|
|
||||||
"videoID": id.value,
|
|
||||||
"category": [
|
|
||||||
'sponsor',
|
|
||||||
'selfpromo',
|
|
||||||
'interaction',
|
|
||||||
'intro',
|
|
||||||
'outro',
|
|
||||||
'music_offtopic'
|
|
||||||
],
|
|
||||||
"actionType": 'skip'
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
if (res.body == "Not Found") {
|
|
||||||
return List.castFrom<dynamic, Map<String, int>>([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = jsonDecode(res.body) as List;
|
|
||||||
final segments = data.map((obj) {
|
|
||||||
return Map.castFrom<String, dynamic, String, int>({
|
|
||||||
"start": obj["segment"].first.toInt(),
|
|
||||||
"end": obj["segment"].last.toInt(),
|
|
||||||
});
|
|
||||||
}).toList();
|
|
||||||
getLogger(Video).v(
|
|
||||||
"[SponsorBlock] successfully fetched skip segments for $title | ${id.value}",
|
|
||||||
);
|
|
||||||
return List.castFrom<dynamic, Map<String, int>>(segments);
|
|
||||||
} catch (e, stack) {
|
|
||||||
Catcher.reportCheckedError(e, stack);
|
|
||||||
return List.castFrom<dynamic, Map<String, int>>([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -9,14 +9,12 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:metadata_god/metadata_god.dart';
|
import 'package:metadata_god/metadata_god.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
|
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
|
||||||
import 'package:spotube/entities/cache_track.dart';
|
|
||||||
import 'package:spotube/collections/routes.dart';
|
import 'package:spotube/collections/routes.dart';
|
||||||
import 'package:spotube/collections/intents.dart';
|
import 'package:spotube/collections/intents.dart';
|
||||||
import 'package:spotube/l10n/l10n.dart';
|
import 'package:spotube/l10n/l10n.dart';
|
||||||
@ -97,9 +95,6 @@ Future<void> main(List<String> rawArgs) async {
|
|||||||
cacheDir: (await getApplicationSupportDirectory()).path,
|
cacheDir: (await getApplicationSupportDirectory()).path,
|
||||||
);
|
);
|
||||||
await PersistedStateNotifier.initializeBoxes();
|
await PersistedStateNotifier.initializeBoxes();
|
||||||
Hive.registerAdapter(CacheTrackAdapter());
|
|
||||||
Hive.registerAdapter(CacheTrackEngagementAdapter());
|
|
||||||
Hive.registerAdapter(CacheTrackSkipSegmentAdapter());
|
|
||||||
|
|
||||||
Catcher(
|
Catcher(
|
||||||
enableLogger: arguments["verbose"],
|
enableLogger: arguments["verbose"],
|
||||||
|
@ -14,12 +14,16 @@ import 'package:spotube/provider/user_preferences_provider.dart';
|
|||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/services/audio_services/audio_services.dart';
|
import 'package:spotube/services/audio_services/audio_services.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|
||||||
|
|
||||||
/// Things to implement:
|
/// Things to implement:
|
||||||
/// * [x] Sponsor-Block skip
|
/// * [x] Sponsor-Block skip
|
||||||
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track
|
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track
|
||||||
/// * [ ] Mixed Queue containing both [SpotubeTrack] and [LocalTrack]
|
/// * [ ] Mixed Queue containing both [SpotubeTrack] and [LocalTrack]
|
||||||
|
/// * [ ] Modification of the Queue
|
||||||
|
/// * [ ] Add track at the end
|
||||||
|
/// * [ ] Add track at the beginning
|
||||||
|
/// * [ ] Remove track
|
||||||
|
/// * [ ] Reorder track
|
||||||
/// * [ ] Caching and loading of cache of tracks
|
/// * [ ] Caching and loading of cache of tracks
|
||||||
/// * [ ] Shuffling and loop => playlist, track, none
|
/// * [ ] Shuffling and loop => playlist, track, none
|
||||||
/// * [ ] Alternative Track Source
|
/// * [ ] Alternative Track Source
|
||||||
|
Loading…
Reference in New Issue
Block a user