mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
feat(playback): change current track youtube source panel and tooltips for player icon buttons
This commit is contained in:
parent
f623768081
commit
4b21cc8299
@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Library/UserLocalTracks.dart';
|
import 'package:spotube/components/Library/UserLocalTracks.dart';
|
||||||
import 'package:spotube/components/Player/PlayerQueue.dart';
|
import 'package:spotube/components/Player/PlayerQueue.dart';
|
||||||
|
import 'package:spotube/components/Player/SiblingTracksSheet.dart';
|
||||||
import 'package:spotube/components/Shared/HeartButton.dart';
|
import 'package:spotube/components/Shared/HeartButton.dart';
|
||||||
import 'package:spotube/models/Logger.dart';
|
import 'package:spotube/models/Logger.dart';
|
||||||
import 'package:spotube/provider/Downloader.dart';
|
import 'package:spotube/provider/Downloader.dart';
|
||||||
@ -49,6 +50,7 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.queue_music_rounded),
|
icon: const Icon(Icons.queue_music_rounded),
|
||||||
|
tooltip: 'Queue',
|
||||||
onPressed: playback.playlist != null
|
onPressed: playback.playlist != null
|
||||||
? () {
|
? () {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
@ -71,6 +73,31 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.alt_route_rounded),
|
||||||
|
tooltip: "Alternative Track Sources",
|
||||||
|
onPressed: playback.track != null
|
||||||
|
? () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isDismissible: true,
|
||||||
|
enableDrag: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.black12,
|
||||||
|
barrierColor: Colors.black12,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * .5,
|
||||||
|
),
|
||||||
|
builder: (context) {
|
||||||
|
return SiblingTracksSheet(floating: floatingQueue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
if (!kIsWeb)
|
if (!kIsWeb)
|
||||||
if (isInQueue)
|
if (isInQueue)
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -82,6 +109,7 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Download track',
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isDownloaded
|
isDownloaded
|
||||||
? Icons.download_done_rounded
|
? Icons.download_done_rounded
|
||||||
|
@ -94,7 +94,9 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Slider.adaptive(
|
Tooltip(
|
||||||
|
message: "Slide to seek forward or backward",
|
||||||
|
child: Slider.adaptive(
|
||||||
focusNode: FocusNode(),
|
focusNode: FocusNode(),
|
||||||
// cannot divide by zero
|
// cannot divide by zero
|
||||||
// there's an edge case for value being bigger
|
// there's an edge case for value being bigger
|
||||||
@ -112,6 +114,7 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
activeColor: iconColor,
|
activeColor: iconColor,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8.0,
|
horizontal: 8.0,
|
||||||
@ -136,6 +139,11 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: playback.isLoop
|
||||||
|
? "Repeat playlist"
|
||||||
|
: playback.isShuffled
|
||||||
|
? "Loop track"
|
||||||
|
: "Shuffle playlist",
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
playback.isLoop
|
playback.isLoop
|
||||||
? Icons.repeat_one_rounded
|
? Icons.repeat_one_rounded
|
||||||
@ -149,12 +157,16 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
: playback.cyclePlaybackMode,
|
: playback.cyclePlaybackMode,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: "Previous track",
|
||||||
icon: const Icon(Icons.skip_previous_rounded),
|
icon: const Icon(Icons.skip_previous_rounded),
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
onPrevious();
|
onPrevious();
|
||||||
}),
|
}),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: playback.isPlaying
|
||||||
|
? "Pause playback"
|
||||||
|
: "Resume playback",
|
||||||
icon: playback.status == PlaybackStatus.loading
|
icon: playback.status == PlaybackStatus.loading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
@ -173,11 +185,13 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: "Next track",
|
||||||
icon: const Icon(Icons.skip_next_rounded),
|
icon: const Icon(Icons.skip_next_rounded),
|
||||||
onPressed: () => onNext(),
|
onPressed: () => onNext(),
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: "Stop playback",
|
||||||
icon: const Icon(Icons.stop_rounded),
|
icon: const Icon(Icons.stop_rounded),
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
onPressed: playback.track != null
|
onPressed: playback.track != null
|
||||||
|
95
lib/components/Player/SiblingTracksSheet.dart
Normal file
95
lib/components/Player/SiblingTracksSheet.dart
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/components/Shared/UniversalImage.dart';
|
||||||
|
import 'package:spotube/provider/Playback.dart';
|
||||||
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
|
|
||||||
|
class SiblingTracksSheet extends HookConsumerWidget {
|
||||||
|
final bool floating;
|
||||||
|
const SiblingTracksSheet({
|
||||||
|
Key? key,
|
||||||
|
this.floating = true,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final playback = ref.watch(playbackProvider);
|
||||||
|
final borderRadius = floating
|
||||||
|
? BorderRadius.circular(10)
|
||||||
|
: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(10),
|
||||||
|
topRight: Radius.circular(10),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (playback.siblingYtVideos.isEmpty) {
|
||||||
|
playback.toSpotubeTrack(playback.track!, ignoreCache: true);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [playback.siblingYtVideos]);
|
||||||
|
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: 12.0,
|
||||||
|
sigmaY: 12.0,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(8.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.navigationRailTheme
|
||||||
|
.backgroundColor
|
||||||
|
?.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
appBar: AppBar(
|
||||||
|
centerTitle: true,
|
||||||
|
title: const Text('Alternative Tracks Sources'),
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: playback.siblingYtVideos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final video = playback.siblingYtVideos[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(video.title),
|
||||||
|
leading: UniversalImage(
|
||||||
|
path: video.thumbnails.lowResUrl,
|
||||||
|
height: 60,
|
||||||
|
width: 60,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
horizontalTitleGap: 10,
|
||||||
|
trailing: Text(
|
||||||
|
PrimitiveUtils.toReadableDuration(
|
||||||
|
video.duration ?? Duration.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(video.author),
|
||||||
|
enabled: playback.status != PlaybackStatus.loading,
|
||||||
|
selected: video.id == playback.track!.ytTrack.id,
|
||||||
|
selectedTileColor: Theme.of(context).popupMenuTheme.color,
|
||||||
|
onTap: () {
|
||||||
|
if (video.id != playback.track!.ytTrack.id) {
|
||||||
|
playback.changeToSiblingVideo(video, playback.track!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -16,10 +16,12 @@ class HeartButton extends ConsumerWidget {
|
|||||||
final void Function()? onPressed;
|
final void Function()? onPressed;
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final Color? color;
|
final Color? color;
|
||||||
|
final String? tooltip;
|
||||||
const HeartButton({
|
const HeartButton({
|
||||||
required this.isLiked,
|
required this.isLiked,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
this.color,
|
this.color,
|
||||||
|
this.tooltip,
|
||||||
this.icon,
|
this.icon,
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@ -31,6 +33,7 @@ class HeartButton extends ConsumerWidget {
|
|||||||
if (!auth.isLoggedIn) return Container();
|
if (!auth.isLoggedIn) return Container();
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
tooltip: tooltip,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
icon ??
|
icon ??
|
||||||
(!isLiked
|
(!isLiked
|
||||||
@ -122,6 +125,7 @@ class TrackHeartButton extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return HeartButton(
|
return HeartButton(
|
||||||
|
tooltip: toggler.item1 ? "Remove from Favorite" : "Add to Favorite",
|
||||||
isLiked: toggler.item1,
|
isLiked: toggler.item1,
|
||||||
onPressed: savedTracks.hasData
|
onPressed: savedTracks.hasData
|
||||||
? () {
|
? () {
|
||||||
@ -181,6 +185,9 @@ class PlaylistHeartButton extends HookConsumerWidget {
|
|||||||
|
|
||||||
return HeartButton(
|
return HeartButton(
|
||||||
isLiked: isLikedQuery.data ?? false,
|
isLiked: isLikedQuery.data ?? false,
|
||||||
|
tooltip: isLikedQuery.data ?? false
|
||||||
|
? "Remove from Favorite"
|
||||||
|
: "Add to Favorite",
|
||||||
color: color?.titleTextColor,
|
color: color?.titleTextColor,
|
||||||
onPressed: isLikedQuery.hasData
|
onPressed: isLikedQuery.hasData
|
||||||
? () {
|
? () {
|
||||||
@ -232,6 +239,7 @@ class AlbumHeartButton extends HookConsumerWidget {
|
|||||||
|
|
||||||
return HeartButton(
|
return HeartButton(
|
||||||
isLiked: isLiked,
|
isLiked: isLiked,
|
||||||
|
tooltip: isLiked ? "Remove from Favorite" : "Add to Favorite",
|
||||||
onPressed: albumIsSaved.hasData
|
onPressed: albumIsSaved.hasData
|
||||||
? () {
|
? () {
|
||||||
toggleAlbumLike
|
toggleAlbumLike
|
||||||
|
@ -66,6 +66,7 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
late LazyBox<CacheTrack> cache;
|
late LazyBox<CacheTrack> cache;
|
||||||
CurrentPlaylist? playlist;
|
CurrentPlaylist? playlist;
|
||||||
SpotubeTrack? track;
|
SpotubeTrack? track;
|
||||||
|
List<Video> _siblingYtVideos = [];
|
||||||
|
|
||||||
// internal stuff
|
// internal stuff
|
||||||
final List<StreamSubscription> _subscriptions;
|
final List<StreamSubscription> _subscriptions;
|
||||||
@ -182,6 +183,20 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> changeToSiblingVideo(Video ytVideo, Track track) async {
|
||||||
|
pause();
|
||||||
|
final siblingYtVideos = _siblingYtVideos;
|
||||||
|
final spotubeTrack = await ytVideoToSpotubeTrack(
|
||||||
|
ytVideo,
|
||||||
|
track,
|
||||||
|
overwriteCache: true,
|
||||||
|
);
|
||||||
|
this.track = null;
|
||||||
|
await play(spotubeTrack.item1, manifest: spotubeTrack.item2);
|
||||||
|
_siblingYtVideos = siblingYtVideos;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> playPlaylist(CurrentPlaylist playlist, [int index = 0]) async {
|
Future<void> playPlaylist(CurrentPlaylist playlist, [int index = 0]) async {
|
||||||
try {
|
try {
|
||||||
if (index < 0 || index > playlist.tracks.length - 1) return;
|
if (index < 0 || index > playlist.tracks.length - 1) return;
|
||||||
@ -204,7 +219,7 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// player methods
|
// player methods
|
||||||
Future<void> play(Track track) async {
|
Future<void> play(Track track, {AudioOnlyStreamInfo? manifest}) async {
|
||||||
_logger.v("[Track Playing] ${track.name} - ${track.id}");
|
_logger.v("[Track Playing] ${track.name} - ${track.id}");
|
||||||
try {
|
try {
|
||||||
// the track is already playing so no need to change that
|
// the track is already playing so no need to change that
|
||||||
@ -213,7 +228,8 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
status = PlaybackStatus.loading;
|
status = PlaybackStatus.loading;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
AudioOnlyStreamInfo? manifest;
|
_siblingYtVideos = [];
|
||||||
|
|
||||||
// the track is not a SpotubeTrack so turning it to one
|
// the track is not a SpotubeTrack so turning it to one
|
||||||
if (track is! SpotubeTrack) {
|
if (track is! SpotubeTrack) {
|
||||||
final s = await toSpotubeTrack(track);
|
final s = await toSpotubeTrack(track);
|
||||||
@ -375,10 +391,83 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Tuple2<SpotubeTrack, AudioOnlyStreamInfo>> ytVideoToSpotubeTrack(
|
||||||
|
Video ytVideo,
|
||||||
|
Track track, {
|
||||||
|
bool noSponsorBlock = false,
|
||||||
|
bool overwriteCache = false,
|
||||||
|
}) async {
|
||||||
|
final cachedTrack = await cache.get(track.id);
|
||||||
|
StreamManifest trackManifest = await raceMultiple(
|
||||||
|
() => youtube.videos.streams.getManifest(ytVideo.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.v(
|
||||||
|
"[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}",
|
||||||
|
);
|
||||||
|
|
||||||
|
final audioManifest = trackManifest.audioOnly.where((info) {
|
||||||
|
final isMp4a = info.codec.mimeType == "audio/mp4";
|
||||||
|
if (kIsLinux) {
|
||||||
|
return !isMp4a;
|
||||||
|
} else if (kIsMacOS || kIsIOS) {
|
||||||
|
return isMp4a;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final chosenStreamInfo = preferences.audioQuality == AudioQuality.high
|
||||||
|
? audioManifest.withHighestBitrate()
|
||||||
|
: audioManifest.sortByBitrate().last;
|
||||||
|
|
||||||
|
final ytUri = chosenStreamInfo.url.toString();
|
||||||
|
|
||||||
|
final skipSegments = cachedTrack?.skipSegments != null &&
|
||||||
|
cachedTrack!.skipSegments!.isNotEmpty
|
||||||
|
? cachedTrack.skipSegments!
|
||||||
|
.map(
|
||||||
|
(segment) => segment.toJson(),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
: noSponsorBlock
|
||||||
|
? List.castFrom<dynamic, Map<String, int>>([])
|
||||||
|
: await getSkipSegments(ytVideo.id.value);
|
||||||
|
|
||||||
|
// only save when the track isn't available in the cache with same
|
||||||
|
// matchAlgorithm
|
||||||
|
if (overwriteCache ||
|
||||||
|
cachedTrack == null ||
|
||||||
|
cachedTrack.mode != preferences.trackMatchAlgorithm.name) {
|
||||||
|
await cache.put(
|
||||||
|
track.id!,
|
||||||
|
CacheTrack.fromVideo(
|
||||||
|
ytVideo,
|
||||||
|
preferences.trackMatchAlgorithm.name,
|
||||||
|
skipSegments: skipSegments,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tuple2(
|
||||||
|
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: ytUri,
|
||||||
|
skipSegments: skipSegments,
|
||||||
|
),
|
||||||
|
chosenStreamInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// playlist & track list methods
|
// playlist & track list methods
|
||||||
Future<Tuple2<SpotubeTrack, AudioOnlyStreamInfo>> toSpotubeTrack(
|
Future<Tuple2<SpotubeTrack, AudioOnlyStreamInfo>> toSpotubeTrack(
|
||||||
Track track, {
|
Track track, {
|
||||||
bool noSponsorBlock = false,
|
bool noSponsorBlock = false,
|
||||||
|
bool ignoreCache = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final format = preferences.ytSearchFormat;
|
final format = preferences.ytSearchFormat;
|
||||||
@ -389,7 +478,6 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
.whereNotNull()
|
.whereNotNull()
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
final audioQuality = preferences.audioQuality;
|
|
||||||
_logger.v("[Track Search Artists] $artistsName");
|
_logger.v("[Track Search Artists] $artistsName");
|
||||||
final mainArtist = artistsName.first;
|
final mainArtist = artistsName.first;
|
||||||
final featuredArtists = artistsName.length > 1
|
final featuredArtists = artistsName.length > 1
|
||||||
@ -409,7 +497,9 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
|
|
||||||
Video ytVideo;
|
Video ytVideo;
|
||||||
final cachedTrack = await cache.get(track.id);
|
final cachedTrack = await cache.get(track.id);
|
||||||
if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) {
|
if (cachedTrack != null &&
|
||||||
|
cachedTrack.mode == matchAlgorithm.name &&
|
||||||
|
!ignoreCache) {
|
||||||
_logger.v(
|
_logger.v(
|
||||||
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
|
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
|
||||||
);
|
);
|
||||||
@ -467,71 +557,19 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ytVideo = ratedRankedVideos.first["video"] as Video;
|
ytVideo = ratedRankedVideos.first["video"] as Video;
|
||||||
|
_siblingYtVideos =
|
||||||
|
ratedRankedVideos.map((e) => e["video"] as Video).toList();
|
||||||
|
notifyListeners();
|
||||||
} else {
|
} else {
|
||||||
ytVideo = videos.where((video) => !video.isLive).first;
|
ytVideo = videos.where((video) => !video.isLive).first;
|
||||||
|
_siblingYtVideos = videos.take(10).toList();
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ytVideoToSpotubeTrack(
|
||||||
StreamManifest trackManifest = await raceMultiple(
|
|
||||||
() => youtube.videos.streams.getManifest(ytVideo.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
_logger.v(
|
|
||||||
"[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}",
|
|
||||||
);
|
|
||||||
|
|
||||||
final audioManifest = trackManifest.audioOnly.where((info) {
|
|
||||||
final isMp4a = info.codec.mimeType == "audio/mp4";
|
|
||||||
if (kIsLinux) {
|
|
||||||
return !isMp4a;
|
|
||||||
} else if (kIsMacOS || kIsIOS) {
|
|
||||||
return isMp4a;
|
|
||||||
} else {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final chosenStreamInfo = audioQuality == AudioQuality.high
|
|
||||||
? audioManifest.withHighestBitrate()
|
|
||||||
: audioManifest.sortByBitrate().last;
|
|
||||||
|
|
||||||
final ytUri = chosenStreamInfo.url.toString();
|
|
||||||
|
|
||||||
final skipSegments = cachedTrack?.skipSegments != null &&
|
|
||||||
cachedTrack!.skipSegments!.isNotEmpty
|
|
||||||
? cachedTrack.skipSegments!
|
|
||||||
.map(
|
|
||||||
(segment) => segment.toJson(),
|
|
||||||
)
|
|
||||||
.toList()
|
|
||||||
: noSponsorBlock
|
|
||||||
? List.castFrom<dynamic, Map<String, int>>([])
|
|
||||||
: await getSkipSegments(ytVideo.id.value);
|
|
||||||
|
|
||||||
// only save when the track isn't available in the cache with same
|
|
||||||
// matchAlgorithm
|
|
||||||
if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) {
|
|
||||||
await cache.put(
|
|
||||||
track.id!,
|
|
||||||
CacheTrack.fromVideo(
|
|
||||||
ytVideo,
|
ytVideo,
|
||||||
matchAlgorithm.name,
|
track,
|
||||||
skipSegments: skipSegments,
|
noSponsorBlock: noSponsorBlock,
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Tuple2(
|
|
||||||
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: ytUri,
|
|
||||||
skipSegments: skipSegments,
|
|
||||||
),
|
|
||||||
chosenStreamInfo,
|
|
||||||
);
|
);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_logger.e("topSpotubeTrack", e, stack);
|
_logger.e("topSpotubeTrack", e, stack);
|
||||||
@ -646,6 +684,8 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
bool get isLoop => playbackMode == PlaybackMode.repeat;
|
bool get isLoop => playbackMode == PlaybackMode.repeat;
|
||||||
bool get isShuffled => playbackMode == PlaybackMode.shuffle;
|
bool get isShuffled => playbackMode == PlaybackMode.shuffle;
|
||||||
bool get isNormal => playbackMode == PlaybackMode.normal;
|
bool get isNormal => playbackMode == PlaybackMode.normal;
|
||||||
|
UnmodifiableListView<Video> get siblingYtVideos =>
|
||||||
|
UnmodifiableListView(_siblingYtVideos);
|
||||||
}
|
}
|
||||||
|
|
||||||
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
|
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
|
||||||
|
@ -34,4 +34,11 @@ abstract class PrimitiveUtils {
|
|||||||
static String zeroPadNumStr(int input) {
|
static String zeroPadNumStr(int input) {
|
||||||
return input < 10 ? "0$input" : input.toString();
|
return input < 10 ? "0$input" : input.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String toReadableDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes % 60;
|
||||||
|
final seconds = duration.inSeconds % 60;
|
||||||
|
return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user