mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Merge branch 'master' into build
This commit is contained in:
commit
7f71440039
73
lib/entities/CacheTrack.dart
Normal file
73
lib/entities/CacheTrack.dart
Normal file
@ -0,0 +1,73 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
part 'CacheTrack.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: 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;
|
||||
|
||||
CacheTrack();
|
||||
|
||||
CacheTrack.fromVideo(Video video, this.mode)
|
||||
: 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);
|
||||
}
|
109
lib/entities/CacheTrack.g.dart
Normal file
109
lib/entities/CacheTrack.g.dart
Normal file
@ -0,0 +1,109 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'CacheTrack.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 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;
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, CacheTrack obj) {
|
||||
writer
|
||||
..writeByte(11)
|
||||
..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);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CacheTrackAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
32
lib/extensions/yt-video-from-cache-track.dart
Normal file
32
lib/extensions/yt-video-from-cache-track.dart
Normal file
@ -0,0 +1,32 @@
|
||||
import 'package:spotube/entities/CacheTrack.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.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,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/entities/CacheTrack.dart';
|
||||
import 'package:spotube/helpers/contains-text-in-bracket.dart';
|
||||
import 'package:spotube/helpers/getLyrics.dart';
|
||||
import 'package:spotube/models/Logger.dart';
|
||||
@ -7,6 +9,7 @@ import 'package:spotube/models/SpotubeTrack.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:spotube/extensions/list-sort-multiple.dart';
|
||||
import 'package:spotube/extensions/yt-video-from-cache-track.dart';
|
||||
|
||||
enum AudioQuality {
|
||||
high,
|
||||
@ -20,6 +23,7 @@ Future<SpotubeTrack> toSpotubeTrack({
|
||||
required String format,
|
||||
required SpotubeTrackMatchAlgorithm matchAlgorithm,
|
||||
required AudioQuality audioQuality,
|
||||
LazyBox<CacheTrack>? box,
|
||||
}) async {
|
||||
final artistsName =
|
||||
track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ??
|
||||
@ -40,53 +44,61 @@ Future<SpotubeTrack> toSpotubeTrack({
|
||||
.replaceAll("\$FEATURED_ARTISTS", featuredArtists);
|
||||
logger.v("[Youtube Search Term] $queryString");
|
||||
|
||||
VideoSearchList videos = await youtube.search.search(queryString);
|
||||
Video ytVideo;
|
||||
|
||||
if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) {
|
||||
List<Map> ratedRankedVideos = videos
|
||||
.map((video) {
|
||||
// the find should be lazy thus everything case insensitive
|
||||
final ytTitle = video.title.toLowerCase();
|
||||
final bool hasTitle = ytTitle.contains(title);
|
||||
final bool hasAllArtists = track.artists?.every(
|
||||
(artist) => ytTitle.contains(artist.name!.toLowerCase()),
|
||||
) ??
|
||||
false;
|
||||
final bool authorIsArtist =
|
||||
track.artists?.first.name?.toLowerCase() ==
|
||||
video.author.toLowerCase();
|
||||
|
||||
final bool hasNoLiveInTitle = !containsTextInBracket(ytTitle, "live");
|
||||
|
||||
int rate = 0;
|
||||
for (final el in [
|
||||
hasTitle,
|
||||
hasAllArtists,
|
||||
if (matchAlgorithm == SpotubeTrackMatchAlgorithm.authenticPopular)
|
||||
authorIsArtist,
|
||||
hasNoLiveInTitle,
|
||||
!video.isLive,
|
||||
]) {
|
||||
if (el) rate++;
|
||||
}
|
||||
// can't let pass any non title matching track
|
||||
if (!hasTitle) rate = rate - 2;
|
||||
return {
|
||||
"video": video,
|
||||
"points": rate,
|
||||
"views": video.engagement.viewCount,
|
||||
};
|
||||
})
|
||||
.toList()
|
||||
.sortByProperties(
|
||||
[false, false],
|
||||
["points", "views"],
|
||||
);
|
||||
|
||||
ytVideo = ratedRankedVideos.first["video"] as Video;
|
||||
final cachedTrack = await box?.get(track.id);
|
||||
if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) {
|
||||
logger.v(
|
||||
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
|
||||
);
|
||||
ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack);
|
||||
} else {
|
||||
ytVideo = videos.where((video) => !video.isLive).first;
|
||||
VideoSearchList videos = await youtube.search.search(queryString);
|
||||
if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) {
|
||||
List<Map> ratedRankedVideos = videos
|
||||
.map((video) {
|
||||
// the find should be lazy thus everything case insensitive
|
||||
final ytTitle = video.title.toLowerCase();
|
||||
final bool hasTitle = ytTitle.contains(title);
|
||||
final bool hasAllArtists = track.artists?.every(
|
||||
(artist) => ytTitle.contains(artist.name!.toLowerCase()),
|
||||
) ??
|
||||
false;
|
||||
final bool authorIsArtist =
|
||||
track.artists?.first.name?.toLowerCase() ==
|
||||
video.author.toLowerCase();
|
||||
|
||||
final bool hasNoLiveInTitle =
|
||||
!containsTextInBracket(ytTitle, "live");
|
||||
|
||||
int rate = 0;
|
||||
for (final el in [
|
||||
hasTitle,
|
||||
hasAllArtists,
|
||||
if (matchAlgorithm == SpotubeTrackMatchAlgorithm.authenticPopular)
|
||||
authorIsArtist,
|
||||
hasNoLiveInTitle,
|
||||
!video.isLive,
|
||||
]) {
|
||||
if (el) rate++;
|
||||
}
|
||||
// can't let pass any non title matching track
|
||||
if (!hasTitle) rate = rate - 2;
|
||||
return {
|
||||
"video": video,
|
||||
"points": rate,
|
||||
"views": video.engagement.viewCount,
|
||||
};
|
||||
})
|
||||
.toList()
|
||||
.sortByProperties(
|
||||
[false, false],
|
||||
["points", "views"],
|
||||
);
|
||||
|
||||
ytVideo = ratedRankedVideos.first["video"] as Video;
|
||||
} else {
|
||||
ytVideo = videos.where((video) => !video.isLive).first;
|
||||
}
|
||||
}
|
||||
|
||||
final trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
|
||||
@ -100,16 +112,25 @@ Future<SpotubeTrack> toSpotubeTrack({
|
||||
.where((info) => info.codec.mimeType == "audio/mp4")
|
||||
: trackManifest.audioOnly;
|
||||
|
||||
final ytUri = (audioQuality == AudioQuality.high
|
||||
? audioManifest.withHighestBitrate()
|
||||
: audioManifest.sortByBitrate().last)
|
||||
.url
|
||||
.toString();
|
||||
|
||||
// only save when the track isn't available in the cache with same
|
||||
// matchAlgorithm
|
||||
if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) {
|
||||
await box?.put(
|
||||
track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name));
|
||||
}
|
||||
|
||||
return SpotubeTrack.fromTrack(
|
||||
track: track,
|
||||
ytTrack: ytVideo,
|
||||
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
|
||||
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
|
||||
// codec/mimetype for those Platforms
|
||||
ytUri: (audioQuality == AudioQuality.high
|
||||
? audioManifest.withHighestBitrate()
|
||||
: audioManifest.sortByBitrate().last)
|
||||
.url
|
||||
.toString(),
|
||||
ytUri: ytUri,
|
||||
);
|
||||
}
|
||||
|
@ -5,8 +5,10 @@ import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'package:spotube/entities/CacheTrack.dart';
|
||||
import 'package:spotube/models/GoRouteDeclarations.dart';
|
||||
import 'package:spotube/models/Logger.dart';
|
||||
import 'package:spotube/provider/AudioPlayer.dart';
|
||||
@ -19,6 +21,9 @@ import 'package:spotube/utils/AudioPlayerHandler.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
void main() async {
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(CacheTrackAdapter());
|
||||
Hive.registerAdapter(CacheTrackEngagementAdapter());
|
||||
AudioPlayerHandler audioPlayerHandler = await AudioService.init(
|
||||
builder: () => AudioPlayerHandler(),
|
||||
config: const AudioServiceConfig(
|
||||
|
@ -3,9 +3,10 @@ import 'dart:convert';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/entities/CacheTrack.dart';
|
||||
import 'package:spotube/helpers/artist-to-string.dart';
|
||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||
import 'package:spotube/helpers/search-youtube.dart';
|
||||
@ -33,6 +34,9 @@ class Playback extends PersistedChangeNotifier {
|
||||
AudioPlayerHandler player;
|
||||
YoutubeExplode youtube;
|
||||
Ref ref;
|
||||
|
||||
LazyBox<CacheTrack>? cacheTrackBox;
|
||||
|
||||
Playback({
|
||||
required this.player,
|
||||
required this.youtube,
|
||||
@ -57,6 +61,8 @@ class Playback extends PersistedChangeNotifier {
|
||||
StreamSubscription<bool>? _playingStream;
|
||||
|
||||
void _init() async {
|
||||
cacheTrackBox = await Hive.openLazyBox<CacheTrack>("track-cache");
|
||||
|
||||
_playingStream = player.core.playingStream.listen(
|
||||
(playing) {
|
||||
_isPlaying = playing;
|
||||
@ -111,6 +117,7 @@ class Playback extends PersistedChangeNotifier {
|
||||
_positionStream?.cancel();
|
||||
_playingStream?.cancel();
|
||||
_durationStream?.cancel();
|
||||
cacheTrackBox?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -213,7 +220,6 @@ class Playback extends PersistedChangeNotifier {
|
||||
notifyListeners();
|
||||
updatePersistence();
|
||||
});
|
||||
// await player.play();
|
||||
return;
|
||||
}
|
||||
final preferences = ref.read(userPreferencesProvider);
|
||||
@ -223,6 +229,7 @@ class Playback extends PersistedChangeNotifier {
|
||||
format: preferences.ytSearchFormat,
|
||||
matchAlgorithm: preferences.trackMatchAlgorithm,
|
||||
audioQuality: preferences.audioQuality,
|
||||
box: cacheTrackBox,
|
||||
);
|
||||
if (setTrackUriById(track.id!, spotubeTrack.ytUri)) {
|
||||
logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}");
|
||||
@ -237,7 +244,6 @@ class Playback extends PersistedChangeNotifier {
|
||||
notifyListeners();
|
||||
updatePersistence();
|
||||
});
|
||||
// await player.play();
|
||||
}
|
||||
}
|
||||
} catch (e, stack) {
|
||||
|
55
lib/utils/duration.dart
Normal file
55
lib/utils/duration.dart
Normal file
@ -0,0 +1,55 @@
|
||||
/// Parses duration string formatted by Duration.toString() to [Duration].
|
||||
/// The string should be of form hours:minutes:seconds.microseconds
|
||||
///
|
||||
/// Example:
|
||||
/// parseTime('245:09:08.007006');
|
||||
Duration parseDuration(String input) {
|
||||
final parts = input.split(':');
|
||||
|
||||
if (parts.length != 3) throw FormatException('Invalid time format');
|
||||
|
||||
int days;
|
||||
int hours;
|
||||
int minutes;
|
||||
int seconds;
|
||||
int milliseconds;
|
||||
int microseconds;
|
||||
|
||||
{
|
||||
final p = parts[2].split('.');
|
||||
|
||||
if (p.length != 2) throw FormatException('Invalid time format');
|
||||
|
||||
final p2 = int.parse(p[1]);
|
||||
microseconds = p2 % 1000;
|
||||
milliseconds = p2 ~/ 1000;
|
||||
|
||||
seconds = int.parse(p[0]);
|
||||
}
|
||||
|
||||
minutes = int.parse(parts[1]);
|
||||
|
||||
{
|
||||
int p = int.parse(parts[0]);
|
||||
hours = p % 24;
|
||||
days = p ~/ 24;
|
||||
}
|
||||
|
||||
// TODO verify that there are no negative parts
|
||||
|
||||
return Duration(
|
||||
days: days,
|
||||
hours: hours,
|
||||
minutes: minutes,
|
||||
seconds: seconds,
|
||||
milliseconds: milliseconds,
|
||||
microseconds: microseconds);
|
||||
}
|
||||
|
||||
Duration? tryParseDuration(String input) {
|
||||
try {
|
||||
return parseDuration(input);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user