feat(playback): change current track youtube source panel and tooltips for player icon buttons

This commit is contained in:
Kingkor Roy Tirtho 2022-10-25 14:12:17 +06:00
parent f623768081
commit 4b21cc8299
6 changed files with 274 additions and 82 deletions

View File

@ -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

View File

@ -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

View 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!);
}
},
);
},
),
),
),
),
);
}
}

View File

@ -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

View File

@ -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) {

View File

@ -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)}";
}
} }