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:spotube/components/Library/UserLocalTracks.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/models/Logger.dart';
|
||||
import 'package:spotube/provider/Downloader.dart';
|
||||
@ -49,6 +50,7 @@ class PlayerActions extends HookConsumerWidget {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music_rounded),
|
||||
tooltip: 'Queue',
|
||||
onPressed: playback.playlist != null
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
@ -71,6 +73,31 @@ class PlayerActions extends HookConsumerWidget {
|
||||
}
|
||||
: 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 (isInQueue)
|
||||
const SizedBox(
|
||||
@ -82,6 +109,7 @@ class PlayerActions extends HookConsumerWidget {
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: 'Download track',
|
||||
icon: Icon(
|
||||
isDownloaded
|
||||
? Icons.download_done_rounded
|
||||
|
@ -94,23 +94,26 @@ class PlayerControls extends HookConsumerWidget {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Slider.adaptive(
|
||||
focusNode: FocusNode(),
|
||||
// cannot divide by zero
|
||||
// there's an edge case for value being bigger
|
||||
// than total duration. Keeping it resolved
|
||||
value: progress.value.toDouble(),
|
||||
onChanged: (v) {
|
||||
progress.value = v;
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
await playback.seekPosition(
|
||||
Duration(
|
||||
seconds: (value * sliderMax).toInt(),
|
||||
),
|
||||
);
|
||||
},
|
||||
activeColor: iconColor,
|
||||
Tooltip(
|
||||
message: "Slide to seek forward or backward",
|
||||
child: Slider.adaptive(
|
||||
focusNode: FocusNode(),
|
||||
// cannot divide by zero
|
||||
// there's an edge case for value being bigger
|
||||
// than total duration. Keeping it resolved
|
||||
value: progress.value.toDouble(),
|
||||
onChanged: (v) {
|
||||
progress.value = v;
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
await playback.seekPosition(
|
||||
Duration(
|
||||
seconds: (value * sliderMax).toInt(),
|
||||
),
|
||||
);
|
||||
},
|
||||
activeColor: iconColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@ -136,6 +139,11 @@ class PlayerControls extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: playback.isLoop
|
||||
? "Repeat playlist"
|
||||
: playback.isShuffled
|
||||
? "Loop track"
|
||||
: "Shuffle playlist",
|
||||
icon: Icon(
|
||||
playback.isLoop
|
||||
? Icons.repeat_one_rounded
|
||||
@ -149,12 +157,16 @@ class PlayerControls extends HookConsumerWidget {
|
||||
: playback.cyclePlaybackMode,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Previous track",
|
||||
icon: const Icon(Icons.skip_previous_rounded),
|
||||
color: iconColor,
|
||||
onPressed: () {
|
||||
onPrevious();
|
||||
}),
|
||||
IconButton(
|
||||
tooltip: playback.isPlaying
|
||||
? "Pause playback"
|
||||
: "Resume playback",
|
||||
icon: playback.status == PlaybackStatus.loading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
@ -173,11 +185,13 @@ class PlayerControls extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Next track",
|
||||
icon: const Icon(Icons.skip_next_rounded),
|
||||
onPressed: () => onNext(),
|
||||
color: iconColor,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Stop playback",
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
color: iconColor,
|
||||
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 IconData? icon;
|
||||
final Color? color;
|
||||
final String? tooltip;
|
||||
const HeartButton({
|
||||
required this.isLiked,
|
||||
required this.onPressed,
|
||||
this.color,
|
||||
this.tooltip,
|
||||
this.icon,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
@ -31,6 +33,7 @@ class HeartButton extends ConsumerWidget {
|
||||
if (!auth.isLoggedIn) return Container();
|
||||
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: Icon(
|
||||
icon ??
|
||||
(!isLiked
|
||||
@ -122,6 +125,7 @@ class TrackHeartButton extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return HeartButton(
|
||||
tooltip: toggler.item1 ? "Remove from Favorite" : "Add to Favorite",
|
||||
isLiked: toggler.item1,
|
||||
onPressed: savedTracks.hasData
|
||||
? () {
|
||||
@ -181,6 +185,9 @@ class PlaylistHeartButton extends HookConsumerWidget {
|
||||
|
||||
return HeartButton(
|
||||
isLiked: isLikedQuery.data ?? false,
|
||||
tooltip: isLikedQuery.data ?? false
|
||||
? "Remove from Favorite"
|
||||
: "Add to Favorite",
|
||||
color: color?.titleTextColor,
|
||||
onPressed: isLikedQuery.hasData
|
||||
? () {
|
||||
@ -232,6 +239,7 @@ class AlbumHeartButton extends HookConsumerWidget {
|
||||
|
||||
return HeartButton(
|
||||
isLiked: isLiked,
|
||||
tooltip: isLiked ? "Remove from Favorite" : "Add to Favorite",
|
||||
onPressed: albumIsSaved.hasData
|
||||
? () {
|
||||
toggleAlbumLike
|
||||
|
@ -66,6 +66,7 @@ class Playback extends PersistedChangeNotifier {
|
||||
late LazyBox<CacheTrack> cache;
|
||||
CurrentPlaylist? playlist;
|
||||
SpotubeTrack? track;
|
||||
List<Video> _siblingYtVideos = [];
|
||||
|
||||
// internal stuff
|
||||
final List<StreamSubscription> _subscriptions;
|
||||
@ -182,6 +183,20 @@ class Playback extends PersistedChangeNotifier {
|
||||
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 {
|
||||
try {
|
||||
if (index < 0 || index > playlist.tracks.length - 1) return;
|
||||
@ -204,7 +219,7 @@ class Playback extends PersistedChangeNotifier {
|
||||
}
|
||||
|
||||
// player methods
|
||||
Future<void> play(Track track) async {
|
||||
Future<void> play(Track track, {AudioOnlyStreamInfo? manifest}) async {
|
||||
_logger.v("[Track Playing] ${track.name} - ${track.id}");
|
||||
try {
|
||||
// the track is already playing so no need to change that
|
||||
@ -213,7 +228,8 @@ class Playback extends PersistedChangeNotifier {
|
||||
status = PlaybackStatus.loading;
|
||||
notifyListeners();
|
||||
}
|
||||
AudioOnlyStreamInfo? manifest;
|
||||
_siblingYtVideos = [];
|
||||
|
||||
// the track is not a SpotubeTrack so turning it to one
|
||||
if (track is! SpotubeTrack) {
|
||||
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
|
||||
Future<Tuple2<SpotubeTrack, AudioOnlyStreamInfo>> toSpotubeTrack(
|
||||
Track track, {
|
||||
bool noSponsorBlock = false,
|
||||
bool ignoreCache = false,
|
||||
}) async {
|
||||
try {
|
||||
final format = preferences.ytSearchFormat;
|
||||
@ -389,7 +478,6 @@ class Playback extends PersistedChangeNotifier {
|
||||
.whereNotNull()
|
||||
.toList() ??
|
||||
[];
|
||||
final audioQuality = preferences.audioQuality;
|
||||
_logger.v("[Track Search Artists] $artistsName");
|
||||
final mainArtist = artistsName.first;
|
||||
final featuredArtists = artistsName.length > 1
|
||||
@ -409,7 +497,9 @@ class Playback extends PersistedChangeNotifier {
|
||||
|
||||
Video ytVideo;
|
||||
final cachedTrack = await cache.get(track.id);
|
||||
if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) {
|
||||
if (cachedTrack != null &&
|
||||
cachedTrack.mode == matchAlgorithm.name &&
|
||||
!ignoreCache) {
|
||||
_logger.v(
|
||||
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
|
||||
);
|
||||
@ -467,71 +557,19 @@ class Playback extends PersistedChangeNotifier {
|
||||
);
|
||||
|
||||
ytVideo = ratedRankedVideos.first["video"] as Video;
|
||||
_siblingYtVideos =
|
||||
ratedRankedVideos.map((e) => e["video"] as Video).toList();
|
||||
notifyListeners();
|
||||
} else {
|
||||
ytVideo = videos.where((video) => !video.isLive).first;
|
||||
_siblingYtVideos = videos.take(10).toList();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
matchAlgorithm.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,
|
||||
return ytVideoToSpotubeTrack(
|
||||
ytVideo,
|
||||
track,
|
||||
noSponsorBlock: noSponsorBlock,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_logger.e("topSpotubeTrack", e, stack);
|
||||
@ -646,6 +684,8 @@ class Playback extends PersistedChangeNotifier {
|
||||
bool get isLoop => playbackMode == PlaybackMode.repeat;
|
||||
bool get isShuffled => playbackMode == PlaybackMode.shuffle;
|
||||
bool get isNormal => playbackMode == PlaybackMode.normal;
|
||||
UnmodifiableListView<Video> get siblingYtVideos =>
|
||||
UnmodifiableListView(_siblingYtVideos);
|
||||
}
|
||||
|
||||
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
|
||||
|
@ -34,4 +34,11 @@ abstract class PrimitiveUtils {
|
||||
static String zeroPadNumStr(int input) {
|
||||
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