mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-05-08 16:24:36 +00:00
feat(connect): add player controls, shuffle, loop, progress bar and queue support
This commit is contained in:
parent
351e833088
commit
22cc210f30
@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart' hide Search;
|
||||
import 'package:spotube/models/spotify/recommendation_seeds.dart';
|
||||
import 'package:spotube/pages/album/album.dart';
|
||||
import 'package:spotube/pages/connect/connect.dart';
|
||||
import 'package:spotube/pages/connect/control/control.dart';
|
||||
import 'package:spotube/pages/getting_started/getting_started.dart';
|
||||
import 'package:spotube/pages/home/genres/genre_playlists.dart';
|
||||
import 'package:spotube/pages/home/genres/genres.dart';
|
||||
@ -175,11 +176,20 @@ final routerProvider = Provider((ref) {
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/connect",
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: ConnectPage(),
|
||||
),
|
||||
)
|
||||
path: "/connect",
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: ConnectPage(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "control",
|
||||
pageBuilder: (context, state) {
|
||||
return const SpotubePage(
|
||||
child: ConnectControlPage(),
|
||||
);
|
||||
},
|
||||
)
|
||||
])
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@ -119,4 +119,5 @@ abstract class SpotubeIcons {
|
||||
static const connect = FeatherIcons.link;
|
||||
static const speaker = FeatherIcons.speaker;
|
||||
static const monitor = FeatherIcons.monitor;
|
||||
static const power = FeatherIcons.power;
|
||||
}
|
||||
|
||||
@ -46,9 +46,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value.activeTrack,
|
||||
));
|
||||
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value.activeTrack is LocalTrack,
|
||||
));
|
||||
final isLocalTrack = currentTrack is LocalTrack;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
useEffect(() {
|
||||
@ -240,7 +238,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
),
|
||||
if (isLocalTrack)
|
||||
Text(
|
||||
currentTrack?.artists?.asString() ?? "",
|
||||
currentTrack.artists?.asString() ?? "",
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: bodyTextColor,
|
||||
@ -304,10 +302,25 @@ class PlayerView extends HookConsumerWidget {
|
||||
.height *
|
||||
.7,
|
||||
),
|
||||
builder: (context) {
|
||||
return const PlayerQueue(
|
||||
floating: false);
|
||||
},
|
||||
builder: (context) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final playlist = ref.watch(
|
||||
ProxyPlaylistNotifier
|
||||
.provider,
|
||||
);
|
||||
final playlistNotifier =
|
||||
ref.read(
|
||||
ProxyPlaylistNotifier
|
||||
.notifier,
|
||||
);
|
||||
return PlayerQueue
|
||||
.fromProxyPlaylistNotifier(
|
||||
floating: false,
|
||||
playlist: playlist,
|
||||
notifier: playlistNotifier,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
: null),
|
||||
|
||||
@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
|
||||
onPressed: playlist.isFetching == true
|
||||
? null
|
||||
: () async {
|
||||
switch (audioPlayer.loopMode) {
|
||||
case PlaybackLoopMode.all:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.one);
|
||||
break;
|
||||
case PlaybackLoopMode.one:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.none);
|
||||
break;
|
||||
case PlaybackLoopMode.none:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.all);
|
||||
break;
|
||||
}
|
||||
audioPlayer.setLoopMode(
|
||||
switch (loopMode) {
|
||||
PlaybackLoopMode.all =>
|
||||
PlaybackLoopMode.one,
|
||||
PlaybackLoopMode.one =>
|
||||
PlaybackLoopMode.none,
|
||||
PlaybackLoopMode.none =>
|
||||
PlaybackLoopMode.all,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
||||
width: double.infinity,
|
||||
color: Colors.transparent,
|
||||
child: PlayerTrackDetails(
|
||||
albumArt: albumArt,
|
||||
track: playlist.activeTrack,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
@ -16,19 +17,40 @@ import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
|
||||
class PlayerQueue extends HookConsumerWidget {
|
||||
final bool floating;
|
||||
final ProxyPlaylist playlist;
|
||||
|
||||
final Future<void> Function(Track track) onJump;
|
||||
final Future<void> Function(String trackId) onRemove;
|
||||
final Future<void> Function(int oldIndex, int newIndex) onReorder;
|
||||
final Future<void> Function() onStop;
|
||||
|
||||
const PlayerQueue({
|
||||
this.floating = true,
|
||||
required this.playlist,
|
||||
required this.onJump,
|
||||
required this.onRemove,
|
||||
required this.onReorder,
|
||||
required this.onStop,
|
||||
super.key,
|
||||
});
|
||||
|
||||
PlayerQueue.fromProxyPlaylistNotifier({
|
||||
this.floating = true,
|
||||
required this.playlist,
|
||||
required ProxyPlaylistNotifier notifier,
|
||||
super.key,
|
||||
}) : onJump = notifier.jumpToTrack,
|
||||
onRemove = notifier.removeTrack,
|
||||
onReorder = notifier.moveTrack,
|
||||
onStop = notifier.stop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final controller = useAutoScrollController();
|
||||
final searchText = useState('');
|
||||
|
||||
@ -191,7 +213,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
playlistNotifier.stop();
|
||||
onStop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
@ -204,7 +226,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
Flexible(
|
||||
child: ReorderableListView.builder(
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
||||
onReorder(oldIndex, newIndex);
|
||||
},
|
||||
scrollController: controller,
|
||||
itemCount: tracks.length,
|
||||
@ -232,7 +254,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
if (playlist.activeTrack?.id == track.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
onJump(track);
|
||||
},
|
||||
leadingActions: [
|
||||
ReorderableDragStartListener(
|
||||
@ -265,7 +287,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
if (playlist.activeTrack?.id == track.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
onJump(track);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@ -8,13 +8,14 @@ import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/components/shared/links/link_text.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
class PlayerTrackDetails extends HookConsumerWidget {
|
||||
final String? albumArt;
|
||||
final Color? color;
|
||||
const PlayerTrackDetails({super.key, this.albumArt, this.color});
|
||||
final Track? track;
|
||||
const PlayerTrackDetails({super.key, this.color, this.track});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: UniversalImage(
|
||||
path: albumArt ?? "",
|
||||
path: (track?.album?.images)
|
||||
.asUrlString(placeholder: ImagePlaceholder.albumArt),
|
||||
placeholder: Assets.albumPlaceholder.path,
|
||||
),
|
||||
),
|
||||
|
||||
@ -75,7 +75,9 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
|
||||
Expanded(
|
||||
child: PlayerTrackDetails(track: playlist.activeTrack),
|
||||
),
|
||||
// controls
|
||||
Flexible(
|
||||
flex: 3,
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||
|
||||
part 'connect.freezed.dart';
|
||||
part 'connect.g.dart';
|
||||
|
||||
@ -2,6 +2,13 @@ part of 'connect.dart';
|
||||
|
||||
enum WsEvent {
|
||||
error,
|
||||
removeTrack,
|
||||
addTrack,
|
||||
reorder,
|
||||
shuffle,
|
||||
loop,
|
||||
seek,
|
||||
duration,
|
||||
queue,
|
||||
position,
|
||||
playing,
|
||||
@ -132,6 +139,92 @@ class WebSocketEvent<T> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onDuration(
|
||||
EventCallback<WebSocketDurationEvent> callback,
|
||||
) async {
|
||||
if (type == WsEvent.duration) {
|
||||
await callback(
|
||||
WebSocketDurationEvent(
|
||||
Duration(seconds: data as int),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onSeek(
|
||||
EventCallback<WebSocketSeekEvent> callback,
|
||||
) async {
|
||||
if (type == WsEvent.seek) {
|
||||
await callback(
|
||||
WebSocketSeekEvent(
|
||||
Duration(seconds: data as int),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onShuffle(
|
||||
EventCallback<WebSocketShuffleEvent> callback,
|
||||
) async {
|
||||
if (type == WsEvent.shuffle) {
|
||||
await callback(WebSocketShuffleEvent(data as bool));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onLoop(
|
||||
EventCallback<WebSocketLoopEvent> callback,
|
||||
) async {
|
||||
if (type == WsEvent.loop) {
|
||||
await callback(
|
||||
WebSocketLoopEvent(
|
||||
PlaybackLoopMode.fromString(data as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onRemoveTrack(
|
||||
EventCallback<WebSocketRemoveTrackEvent> callback,
|
||||
) async {
|
||||
if (type == WsEvent.removeTrack) {
|
||||
await callback(WebSocketRemoveTrackEvent(data as String));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onAddTrack(
|
||||
EventCallback<WebSocketAddTrackEvent> callback,
|
||||
) async {
|
||||
if (type == WsEvent.addTrack) {
|
||||
await callback(
|
||||
WebSocketAddTrackEvent.fromJson(data as Map<String, dynamic>));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onReorder(
|
||||
EventCallback<WebSocketReorderEvent> callback,
|
||||
) async {
|
||||
if (type == WsEvent.reorder) {
|
||||
await callback(
|
||||
WebSocketReorderEvent.fromJson(data as Map<String, dynamic>));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketLoopEvent extends WebSocketEvent<PlaybackLoopMode> {
|
||||
WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data);
|
||||
|
||||
WebSocketLoopEvent.fromJson(Map<String, dynamic> json)
|
||||
: super(
|
||||
WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String));
|
||||
|
||||
@override
|
||||
String toJson() {
|
||||
return jsonEncode({
|
||||
"type": type.name,
|
||||
"data": data.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketPositionEvent extends WebSocketEvent<Duration> {
|
||||
@ -149,6 +242,40 @@ class WebSocketPositionEvent extends WebSocketEvent<Duration> {
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketDurationEvent extends WebSocketEvent<Duration> {
|
||||
WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data);
|
||||
|
||||
WebSocketDurationEvent.fromJson(Map<String, dynamic> json)
|
||||
: super(WsEvent.duration, Duration(seconds: json["data"] as int));
|
||||
|
||||
@override
|
||||
String toJson() {
|
||||
return jsonEncode({
|
||||
"type": type.name,
|
||||
"data": data.inSeconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketSeekEvent extends WebSocketEvent<Duration> {
|
||||
WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data);
|
||||
|
||||
WebSocketSeekEvent.fromJson(Map<String, dynamic> json)
|
||||
: super(WsEvent.seek, Duration(seconds: json["data"] as int));
|
||||
|
||||
@override
|
||||
String toJson() {
|
||||
return jsonEncode({
|
||||
"type": type.name,
|
||||
"data": data.inSeconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketShuffleEvent extends WebSocketEvent<bool> {
|
||||
WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data);
|
||||
}
|
||||
|
||||
class WebSocketPlayingEvent extends WebSocketEvent<bool> {
|
||||
WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data);
|
||||
}
|
||||
@ -189,3 +316,42 @@ class WebSocketQueueEvent extends WebSocketEvent<ProxyPlaylist> {
|
||||
ProxyPlaylist.fromJsonRaw(json),
|
||||
);
|
||||
}
|
||||
|
||||
class WebSocketRemoveTrackEvent extends WebSocketEvent<String> {
|
||||
WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data);
|
||||
}
|
||||
|
||||
class WebSocketAddTrackEvent extends WebSocketEvent<Track> {
|
||||
WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data);
|
||||
|
||||
WebSocketAddTrackEvent.fromJson(Map<String, dynamic> json)
|
||||
: super(
|
||||
WsEvent.addTrack,
|
||||
Track.fromJson(json["data"] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
typedef ReorderData = ({int oldIndex, int newIndex});
|
||||
|
||||
class WebSocketReorderEvent extends WebSocketEvent<ReorderData> {
|
||||
WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data);
|
||||
|
||||
factory WebSocketReorderEvent.fromJson(Map<String, dynamic> json) =>
|
||||
WebSocketReorderEvent(
|
||||
(
|
||||
oldIndex: json["oldIndex"] as int,
|
||||
newIndex: json["newIndex"] as int,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
String toJson() {
|
||||
return jsonEncode({
|
||||
"type": type.name,
|
||||
"data": {
|
||||
"oldIndex": data.oldIndex,
|
||||
"newIndex": data.newIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/provider/connect/clients.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
class ConnectPage extends HookConsumerWidget {
|
||||
const ConnectPage({super.key});
|
||||
@ -46,12 +47,21 @@ class ConnectPage extends HookConsumerWidget {
|
||||
selectedTileColor: colorScheme.secondary.withOpacity(0.1),
|
||||
onTap: () {
|
||||
if (selected) {
|
||||
connectClientsNotifier.clearResolvedService();
|
||||
ServiceUtils.push(
|
||||
context,
|
||||
"/connect/control",
|
||||
);
|
||||
} else {
|
||||
connectClientsNotifier.resolveService(device);
|
||||
}
|
||||
},
|
||||
trailing: selected ? const Icon(SpotubeIcons.done) : null,
|
||||
trailing: selected
|
||||
? IconButton(
|
||||
icon: const Icon(SpotubeIcons.power),
|
||||
onPressed: () =>
|
||||
connectClientsNotifier.clearResolvedService(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
269
lib/pages/connect/control/control.dart
Normal file
269
lib/pages/connect/control/control.dart
Normal file
@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/player/player_queue.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/links/anchor_button.dart';
|
||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/connect/clients.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
class ConnectControlPage extends HookConsumerWidget {
|
||||
const ConnectControlPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
|
||||
|
||||
final resolvedService =
|
||||
ref.watch(connectClientsProvider).asData?.value.resolvedService;
|
||||
final connectNotifier = ref.read(connectProvider.notifier);
|
||||
final playlist = ref.watch(queueProvider);
|
||||
final playing = ref.watch(playingProvider);
|
||||
final shuffled = ref.watch(shuffleProvider);
|
||||
final loopMode = ref.watch(loopModeProvider);
|
||||
|
||||
final resumePauseStyle = IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.all(12),
|
||||
iconSize: 24,
|
||||
);
|
||||
final buttonStyle = IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surface.withOpacity(0.4),
|
||||
minimumSize: const Size(28, 28),
|
||||
);
|
||||
|
||||
final activeButtonStyle = IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
foregroundColor: colorScheme.onPrimaryContainer,
|
||||
minimumSize: const Size(28, 28),
|
||||
);
|
||||
|
||||
ref.listen(connectClientsProvider, (prev, next) {
|
||||
if (next.asData?.value.resolvedService == null) {
|
||||
context.pop();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: PageWindowTitleBar(
|
||||
title: Text(resolvedService!.name),
|
||||
automaticallyImplyLeading: true,
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: UniversalImage(
|
||||
path: (playlist.activeTrack?.album?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverGap(10),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
sliver: SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: AnchorButton(
|
||||
playlist.activeTrack?.name ?? "",
|
||||
style: textTheme.titleLarge!,
|
||||
onTap: () {
|
||||
ServiceUtils.push(
|
||||
context,
|
||||
"/track/${playlist.activeTrack?.id}",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ArtistLink(
|
||||
artists: playlist.activeTrack?.artists ?? [],
|
||||
textStyle: textTheme.bodyMedium!,
|
||||
mainAxisAlignment: WrapAlignment.start,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverGap(30),
|
||||
SliverToBoxAdapter(
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final position = ref.watch(positionProvider);
|
||||
final duration = ref.watch(durationProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Slider(
|
||||
value: position > duration
|
||||
? 0
|
||||
: position.inSeconds.toDouble(),
|
||||
min: 0,
|
||||
max: duration.inSeconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
connectNotifier
|
||||
.seek(Duration(seconds: value.toInt()));
|
||||
},
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(position.toHumanReadableString()),
|
||||
Text(duration.toHumanReadableString()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: shuffled
|
||||
? context.l10n.unshuffle_playlist
|
||||
: context.l10n.shuffle_playlist,
|
||||
icon: const Icon(SpotubeIcons.shuffle),
|
||||
style: shuffled ? activeButtonStyle : buttonStyle,
|
||||
onPressed: playlist.activeTrack == null
|
||||
? null
|
||||
: () {
|
||||
connectNotifier.setShuffle(!shuffled);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: context.l10n.previous_track,
|
||||
icon: const Icon(SpotubeIcons.skipBack),
|
||||
onPressed: playlist.activeTrack == null
|
||||
? null
|
||||
: connectNotifier.previous,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: playing
|
||||
? context.l10n.pause_playback
|
||||
: context.l10n.resume_playback,
|
||||
icon: playlist.activeTrack == null
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
playing ? SpotubeIcons.pause : SpotubeIcons.play,
|
||||
),
|
||||
style: resumePauseStyle,
|
||||
onPressed: playlist.activeTrack == null
|
||||
? null
|
||||
: () {
|
||||
if (playing) {
|
||||
connectNotifier.pause();
|
||||
} else {
|
||||
connectNotifier.resume();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: context.l10n.next_track,
|
||||
icon: const Icon(SpotubeIcons.skipForward),
|
||||
onPressed: playlist.activeTrack == null
|
||||
? null
|
||||
: connectNotifier.next,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: loopMode == PlaybackLoopMode.one
|
||||
? context.l10n.loop_track
|
||||
: loopMode == PlaybackLoopMode.all
|
||||
? context.l10n.repeat_playlist
|
||||
: null,
|
||||
icon: Icon(
|
||||
loopMode == PlaybackLoopMode.one
|
||||
? SpotubeIcons.repeatOne
|
||||
: SpotubeIcons.repeat,
|
||||
),
|
||||
style: loopMode == PlaybackLoopMode.one ||
|
||||
loopMode == PlaybackLoopMode.all
|
||||
? activeButtonStyle
|
||||
: buttonStyle,
|
||||
onPressed: playlist.activeTrack == null
|
||||
? null
|
||||
: () async {
|
||||
connectNotifier.setLoopMode(
|
||||
switch (loopMode) {
|
||||
PlaybackLoopMode.all => PlaybackLoopMode.one,
|
||||
PlaybackLoopMode.one => PlaybackLoopMode.none,
|
||||
PlaybackLoopMode.none => PlaybackLoopMode.all,
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverGap(30),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(SpotubeIcons.queue),
|
||||
label: Text(context.l10n.queue),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Consumer(builder: (context, ref, _) {
|
||||
final playlist = ref.watch(queueProvider);
|
||||
return PlayerQueue(
|
||||
playlist: playlist,
|
||||
floating: true,
|
||||
onJump: (track) async {
|
||||
final index =
|
||||
playlist.tracks.toList().indexOf(track);
|
||||
connectNotifier.jumpTo(index);
|
||||
},
|
||||
onRemove: (track) async {
|
||||
await connectNotifier.removeTrack(track);
|
||||
},
|
||||
onStop: () async => connectNotifier.stop(),
|
||||
onReorder: (oldIndex, newIndex) async {
|
||||
await connectNotifier.reorder(
|
||||
(oldIndex: oldIndex, newIndex: newIndex),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget {
|
||||
MediaQuery.of(context).size.height * .7,
|
||||
),
|
||||
builder: (context) {
|
||||
return const PlayerQueue(floating: true);
|
||||
return Consumer(builder: (context, ref, _) {
|
||||
final playlist = ref
|
||||
.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
return PlayerQueue
|
||||
.fromProxyPlaylistNotifier(
|
||||
floating: true,
|
||||
playlist: playlist,
|
||||
notifier: ref
|
||||
.read(ProxyPlaylistNotifier.notifier),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/configurators/use_endless_playback.dart';
|
||||
import 'package:spotube/hooks/configurators/use_update_checker.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/connectivity_adapter.dart';
|
||||
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||
|
||||
@ -191,7 +192,19 @@ class RootApp extends HookConsumerWidget {
|
||||
top: 40,
|
||||
bottom: 100,
|
||||
),
|
||||
child: const PlayerQueue(floating: true),
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier =
|
||||
ref.read(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
return PlayerQueue.fromProxyPlaylistNotifier(
|
||||
floating: true,
|
||||
playlist: playlist,
|
||||
notifier: playlistNotifier,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar: Column(
|
||||
|
||||
@ -52,10 +52,15 @@ class ConnectClientsNotifier extends AsyncNotifier<ConnectClientsState> {
|
||||
break;
|
||||
case BonsoirDiscoveryEventType.discoveryServiceLost:
|
||||
state = AsyncData(
|
||||
state.value!.copyWith(
|
||||
ConnectClientsState(
|
||||
services: state.value!.services
|
||||
.where((service) => service.name != event.service!.name)
|
||||
.where((s) => s.name != event.service!.name)
|
||||
.toList(),
|
||||
discovery: state.value!.discovery,
|
||||
resolvedService:
|
||||
event.service?.name == state.value!.resolvedService!.name
|
||||
? null
|
||||
: state.value!.resolvedService,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
@ -1,22 +1,37 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:catcher_2/catcher_2.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/clients.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/status.dart' as status;
|
||||
|
||||
final playingStreamController = StreamController<bool>.broadcast();
|
||||
final playingProvider = StreamProvider.autoDispose<bool>(
|
||||
(ref) => playingStreamController.stream,
|
||||
final playingProvider = StateProvider<bool>(
|
||||
(ref) => false,
|
||||
);
|
||||
|
||||
final positionStreamController = StreamController<Duration>.broadcast();
|
||||
final positionProvider = StreamProvider.autoDispose<Duration>(
|
||||
(ref) => positionStreamController.stream,
|
||||
final positionProvider = StateProvider<Duration>(
|
||||
(ref) => Duration.zero,
|
||||
);
|
||||
|
||||
final durationProvider = StateProvider<Duration>(
|
||||
(ref) => Duration.zero,
|
||||
);
|
||||
|
||||
final shuffleProvider = StateProvider<bool>(
|
||||
(ref) => false,
|
||||
);
|
||||
|
||||
final loopModeProvider = StateProvider<PlaybackLoopMode>(
|
||||
(ref) => PlaybackLoopMode.none,
|
||||
);
|
||||
|
||||
final queueProvider = StateProvider<ProxyPlaylist>(
|
||||
(ref) => ProxyPlaylist({}),
|
||||
);
|
||||
|
||||
class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
|
||||
@ -48,15 +63,27 @@ class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
|
||||
WebSocketEvent.fromJson(jsonDecode(message), (data) => data);
|
||||
|
||||
event.onQueue((event) {
|
||||
ref.read(ProxyPlaylistNotifier.notifier).state = event.data;
|
||||
ref.read(queueProvider.notifier).state = event.data;
|
||||
});
|
||||
|
||||
event.onPlaying((event) {
|
||||
playingStreamController.add(event.data);
|
||||
ref.read(playingProvider.notifier).state = event.data;
|
||||
});
|
||||
|
||||
event.onPosition((event) {
|
||||
positionStreamController.add(event.data);
|
||||
ref.read(positionProvider.notifier).state = event.data;
|
||||
});
|
||||
|
||||
event.onDuration((event) {
|
||||
ref.read(durationProvider.notifier).state = event.data;
|
||||
});
|
||||
|
||||
event.onShuffle((event) {
|
||||
ref.read(shuffleProvider.notifier).state = event.data;
|
||||
});
|
||||
|
||||
event.onLoop((event) {
|
||||
ref.read(loopModeProvider.notifier).state = event.data;
|
||||
});
|
||||
},
|
||||
onError: (error) {
|
||||
@ -79,40 +106,64 @@ class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
|
||||
}
|
||||
}
|
||||
|
||||
void emit(Object message) {
|
||||
Future<void> emit(Object message) async {
|
||||
if (state.value == null) return;
|
||||
state.value?.sink.add(
|
||||
message is String ? message : (message as dynamic).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
void resume() {
|
||||
Future<void> resume() async {
|
||||
emit(WebSocketResumeEvent());
|
||||
}
|
||||
|
||||
void pause() {
|
||||
Future<void> pause() async {
|
||||
emit(WebSocketPauseEvent());
|
||||
}
|
||||
|
||||
void stop() {
|
||||
Future<void> stop() async {
|
||||
emit(WebSocketStopEvent());
|
||||
}
|
||||
|
||||
void jumpTo(int position) {
|
||||
Future<void> jumpTo(int position) async {
|
||||
emit(WebSocketJumpEvent(position));
|
||||
}
|
||||
|
||||
void load(WebSocketLoadEventData data) {
|
||||
Future<void> load(WebSocketLoadEventData data) async {
|
||||
emit(WebSocketLoadEvent(data));
|
||||
}
|
||||
|
||||
void next() {
|
||||
Future<void> next() async {
|
||||
emit(WebSocketNextEvent());
|
||||
}
|
||||
|
||||
void previous() {
|
||||
Future<void> previous() async {
|
||||
emit(WebSocketPreviousEvent());
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
emit(WebSocketSeekEvent(position));
|
||||
}
|
||||
|
||||
Future<void> setShuffle(bool value) async {
|
||||
emit(WebSocketShuffleEvent(value));
|
||||
}
|
||||
|
||||
Future<void> setLoopMode(PlaybackLoopMode value) async {
|
||||
emit(WebSocketLoopEvent(value));
|
||||
}
|
||||
|
||||
Future<void> addTrack(Track data) async {
|
||||
emit(WebSocketAddTrackEvent(data));
|
||||
}
|
||||
|
||||
Future<void> removeTrack(String data) async {
|
||||
emit(WebSocketRemoveTrackEvent(data));
|
||||
}
|
||||
|
||||
Future<void> reorder(ReorderData data) async {
|
||||
emit(WebSocketReorderEvent(data));
|
||||
}
|
||||
}
|
||||
|
||||
final connectProvider =
|
||||
|
||||
@ -11,6 +11,7 @@ import 'package:shelf_router/shelf_router.dart';
|
||||
import 'package:shelf_web_socket/shelf_web_socket.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/connect/clients.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
@ -24,9 +25,11 @@ final logger = getLogger('ConnectServer');
|
||||
final connectServerProvider = FutureProvider((ref) async {
|
||||
final enabled =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.enableConnect));
|
||||
final resolvedService = await ref
|
||||
.watch(connectClientsProvider.selectAsync((s) => s.resolvedService));
|
||||
final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
if (!enabled) {
|
||||
if (!enabled || resolvedService != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -42,7 +45,7 @@ final connectServerProvider = FutureProvider((ref) async {
|
||||
final subscriptions = <StreamSubscription>[];
|
||||
|
||||
final websocket = webSocketHandler(
|
||||
(WebSocketChannel channel, String? protocol) {
|
||||
(WebSocketChannel channel, String? protocol) async {
|
||||
ref.listen(
|
||||
ProxyPlaylistNotifier.provider,
|
||||
(previous, next) {
|
||||
@ -53,6 +56,25 @@ final connectServerProvider = FutureProvider((ref) async {
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
// because audioPlayer events doesn't fireImmediately
|
||||
channel.sink.add(
|
||||
WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(),
|
||||
);
|
||||
channel.sink.add(
|
||||
WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero)
|
||||
.toJson(),
|
||||
);
|
||||
channel.sink.add(
|
||||
WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero)
|
||||
.toJson(),
|
||||
);
|
||||
channel.sink.add(
|
||||
WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(),
|
||||
);
|
||||
channel.sink.add(
|
||||
WebSocketLoopEvent(audioPlayer.loopMode).toJson(),
|
||||
);
|
||||
|
||||
subscriptions.addAll([
|
||||
audioPlayer.positionStream.listen(
|
||||
(position) {
|
||||
@ -64,7 +86,28 @@ final connectServerProvider = FutureProvider((ref) async {
|
||||
audioPlayer.playingStream.listen(
|
||||
(playing) {
|
||||
channel.sink.add(
|
||||
WebSocketEvent(WsEvent.playing, playing).toJson(),
|
||||
WebSocketPlayingEvent(playing).toJson(),
|
||||
);
|
||||
},
|
||||
),
|
||||
audioPlayer.durationStream.listen(
|
||||
(duration) {
|
||||
channel.sink.add(
|
||||
WebSocketDurationEvent(duration).toJson(),
|
||||
);
|
||||
},
|
||||
),
|
||||
audioPlayer.shuffledStream.listen(
|
||||
(shuffled) {
|
||||
channel.sink.add(
|
||||
WebSocketShuffleEvent(shuffled).toJson(),
|
||||
);
|
||||
},
|
||||
),
|
||||
audioPlayer.loopModeStream.listen(
|
||||
(loopMode) {
|
||||
channel.sink.add(
|
||||
WebSocketLoopEvent(loopMode).toJson(),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -111,6 +154,33 @@ final connectServerProvider = FutureProvider((ref) async {
|
||||
event.onJump((event) async {
|
||||
await playbackNotifier.jumpTo(event.data);
|
||||
});
|
||||
|
||||
event.onSeek((event) async {
|
||||
await audioPlayer.seek(event.data);
|
||||
});
|
||||
|
||||
event.onShuffle((event) async {
|
||||
await audioPlayer.setShuffle(event.data);
|
||||
});
|
||||
|
||||
event.onLoop((event) async {
|
||||
await audioPlayer.setLoopMode(event.data);
|
||||
});
|
||||
|
||||
event.onAddTrack((event) async {
|
||||
await playbackNotifier.addTrack(event.data);
|
||||
});
|
||||
|
||||
event.onRemoveTrack((event) async {
|
||||
await playbackNotifier.removeTrack(event.data);
|
||||
});
|
||||
|
||||
event.onReorder((event) async {
|
||||
await playbackNotifier.moveTrack(
|
||||
event.data.oldIndex,
|
||||
event.data.newIndex,
|
||||
);
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
Catcher2.reportCheckedError(e, stackTrace);
|
||||
channel.sink.add(WebSocketErrorEvent(e.toString()).toJson());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user