feat(connect): add player controls, shuffle, loop, progress bar and queue support

This commit is contained in:
Kingkor Roy Tirtho 2024-03-25 22:02:48 +06:00
parent 351e833088
commit 22cc210f30
17 changed files with 708 additions and 66 deletions

View File

@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/connect/connect.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/getting_started/getting_started.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/genres/genres.dart';
@ -175,11 +176,20 @@ final routerProvider = Provider((ref) {
}, },
), ),
GoRoute( GoRoute(
path: "/connect", path: "/connect",
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: ConnectPage(), child: ConnectPage(),
), ),
) routes: [
GoRoute(
path: "control",
pageBuilder: (context, state) {
return const SpotubePage(
child: ConnectControlPage(),
);
},
)
])
], ],
), ),
GoRoute( GoRoute(

View File

@ -119,4 +119,5 @@ abstract class SpotubeIcons {
static const connect = FeatherIcons.link; static const connect = FeatherIcons.link;
static const speaker = FeatherIcons.speaker; static const speaker = FeatherIcons.speaker;
static const monitor = FeatherIcons.monitor; static const monitor = FeatherIcons.monitor;
static const power = FeatherIcons.power;
} }

View File

@ -46,9 +46,7 @@ class PlayerView extends HookConsumerWidget {
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
(value) => value.activeTrack, (value) => value.activeTrack,
)); ));
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( final isLocalTrack = currentTrack is LocalTrack;
(value) => value.activeTrack is LocalTrack,
));
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
useEffect(() { useEffect(() {
@ -240,7 +238,7 @@ class PlayerView extends HookConsumerWidget {
), ),
if (isLocalTrack) if (isLocalTrack)
Text( Text(
currentTrack?.artists?.asString() ?? "", currentTrack.artists?.asString() ?? "",
style: theme.textTheme.bodyMedium!.copyWith( style: theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: bodyTextColor, color: bodyTextColor,
@ -304,10 +302,25 @@ class PlayerView extends HookConsumerWidget {
.height * .height *
.7, .7,
), ),
builder: (context) { builder: (context) => Consumer(
return const PlayerQueue( builder: (context, ref, _) {
floating: false); final playlist = ref.watch(
}, ProxyPlaylistNotifier
.provider,
);
final playlistNotifier =
ref.read(
ProxyPlaylistNotifier
.notifier,
);
return PlayerQueue
.fromProxyPlaylistNotifier(
floating: false,
playlist: playlist,
notifier: playlistNotifier,
);
},
),
); );
} }
: null), : null),

View File

@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
onPressed: playlist.isFetching == true onPressed: playlist.isFetching == true
? null ? null
: () async { : () async {
switch (audioPlayer.loopMode) { audioPlayer.setLoopMode(
case PlaybackLoopMode.all: switch (loopMode) {
audioPlayer PlaybackLoopMode.all =>
.setLoopMode(PlaybackLoopMode.one); PlaybackLoopMode.one,
break; PlaybackLoopMode.one =>
case PlaybackLoopMode.one: PlaybackLoopMode.none,
audioPlayer PlaybackLoopMode.none =>
.setLoopMode(PlaybackLoopMode.none); PlaybackLoopMode.all,
break; },
case PlaybackLoopMode.none: );
audioPlayer
.setLoopMode(PlaybackLoopMode.all);
break;
}
}, },
); );
}), }),

View File

@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget {
width: double.infinity, width: double.infinity,
color: Colors.transparent, color: Colors.transparent,
child: PlayerTrackDetails( child: PlayerTrackDetails(
albumArt: albumArt, track: playlist.activeTrack,
color: textColor, color: textColor,
), ),
), ),

View File

@ -8,6 +8,7 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.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/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.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/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.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'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
class PlayerQueue extends HookConsumerWidget { class PlayerQueue extends HookConsumerWidget {
final bool floating; 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({ const PlayerQueue({
this.floating = true, this.floating = true,
required this.playlist,
required this.onJump,
required this.onRemove,
required this.onReorder,
required this.onStop,
super.key, 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 @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final controller = useAutoScrollController(); final controller = useAutoScrollController();
final searchText = useState(''); final searchText = useState('');
@ -191,7 +213,7 @@ class PlayerQueue extends HookConsumerWidget {
], ],
), ),
onPressed: () { onPressed: () {
playlistNotifier.stop(); onStop();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
@ -204,7 +226,7 @@ class PlayerQueue extends HookConsumerWidget {
Flexible( Flexible(
child: ReorderableListView.builder( child: ReorderableListView.builder(
onReorder: (oldIndex, newIndex) { onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex); onReorder(oldIndex, newIndex);
}, },
scrollController: controller, scrollController: controller,
itemCount: tracks.length, itemCount: tracks.length,
@ -232,7 +254,7 @@ class PlayerQueue extends HookConsumerWidget {
if (playlist.activeTrack?.id == track.id) { if (playlist.activeTrack?.id == track.id) {
return; return;
} }
await playlistNotifier.jumpToTrack(track); onJump(track);
}, },
leadingActions: [ leadingActions: [
ReorderableDragStartListener( ReorderableDragStartListener(
@ -265,7 +287,7 @@ class PlayerQueue extends HookConsumerWidget {
if (playlist.activeTrack?.id == track.id) { if (playlist.activeTrack?.id == track.id) {
return; return;
} }
await playlistNotifier.jumpToTrack(track); onJump(track);
}, },
), ),
); );

View File

@ -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/components/shared/links/link_text.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.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/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget { class PlayerTrackDetails extends HookConsumerWidget {
final String? albumArt;
final Color? color; final Color? color;
const PlayerTrackDetails({super.key, this.albumArt, this.color}); final Track? track;
const PlayerTrackDetails({super.key, this.color, this.track});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: UniversalImage( child: UniversalImage(
path: albumArt ?? "", path: (track?.album?.images)
.asUrlString(placeholder: ImagePlaceholder.albumArt),
placeholder: Assets.albumPlaceholder.path, placeholder: Assets.albumPlaceholder.path,
), ),
), ),

View File

@ -75,7 +75,9 @@ class BottomPlayer extends HookConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded(child: PlayerTrackDetails(albumArt: albumArt)), Expanded(
child: PlayerTrackDetails(track: playlist.activeTrack),
),
// controls // controls
Flexible( Flexible(
flex: 3, flex: 3,

View File

@ -7,6 +7,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.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.freezed.dart';
part 'connect.g.dart'; part 'connect.g.dart';

View File

@ -2,6 +2,13 @@ part of 'connect.dart';
enum WsEvent { enum WsEvent {
error, error,
removeTrack,
addTrack,
reorder,
shuffle,
loop,
seek,
duration,
queue, queue,
position, position,
playing, 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> { 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> { class WebSocketPlayingEvent extends WebSocketEvent<bool> {
WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data); WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data);
} }
@ -189,3 +316,42 @@ class WebSocketQueueEvent extends WebSocketEvent<ProxyPlaylist> {
ProxyPlaylist.fromJsonRaw(json), 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,
},
});
}
}

View File

@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/utils/service_utils.dart';
class ConnectPage extends HookConsumerWidget { class ConnectPage extends HookConsumerWidget {
const ConnectPage({super.key}); const ConnectPage({super.key});
@ -46,12 +47,21 @@ class ConnectPage extends HookConsumerWidget {
selectedTileColor: colorScheme.secondary.withOpacity(0.1), selectedTileColor: colorScheme.secondary.withOpacity(0.1),
onTap: () { onTap: () {
if (selected) { if (selected) {
connectClientsNotifier.clearResolvedService(); ServiceUtils.push(
context,
"/connect/control",
);
} else { } else {
connectClientsNotifier.resolveService(device); connectClientsNotifier.resolveService(device);
} }
}, },
trailing: selected ? const Icon(SpotubeIcons.done) : null, trailing: selected
? IconButton(
icon: const Icon(SpotubeIcons.power),
onPressed: () =>
connectClientsNotifier.clearResolvedService(),
)
: null,
), ),
); );
}, },

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

View File

@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget {
MediaQuery.of(context).size.height * .7, MediaQuery.of(context).size.height * .7,
), ),
builder: (context) { 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),
);
});
}, },
); );
} }

View File

@ -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_endless_playback.dart';
import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart';
import 'package:spotube/provider/download_manager_provider.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/services/connectivity_adapter.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
@ -191,7 +192,19 @@ class RootApp extends HookConsumerWidget {
top: 40, top: 40,
bottom: 100, 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, : null,
bottomNavigationBar: Column( bottomNavigationBar: Column(

View File

@ -52,10 +52,15 @@ class ConnectClientsNotifier extends AsyncNotifier<ConnectClientsState> {
break; break;
case BonsoirDiscoveryEventType.discoveryServiceLost: case BonsoirDiscoveryEventType.discoveryServiceLost:
state = AsyncData( state = AsyncData(
state.value!.copyWith( ConnectClientsState(
services: state.value!.services services: state.value!.services
.where((service) => service.name != event.service!.name) .where((s) => s.name != event.service!.name)
.toList(), .toList(),
discovery: state.value!.discovery,
resolvedService:
event.service?.name == state.value!.resolvedService!.name
? null
: state.value!.resolvedService,
), ),
); );
break; break;

View File

@ -1,22 +1,37 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:catcher_2/catcher_2.dart'; import 'package:catcher_2/catcher_2.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/clients.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/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/status.dart' as status;
final playingStreamController = StreamController<bool>.broadcast(); final playingProvider = StateProvider<bool>(
final playingProvider = StreamProvider.autoDispose<bool>( (ref) => false,
(ref) => playingStreamController.stream,
); );
final positionStreamController = StreamController<Duration>.broadcast(); final positionProvider = StateProvider<Duration>(
final positionProvider = StreamProvider.autoDispose<Duration>( (ref) => Duration.zero,
(ref) => positionStreamController.stream, );
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?> { class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
@ -48,15 +63,27 @@ class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
WebSocketEvent.fromJson(jsonDecode(message), (data) => data); WebSocketEvent.fromJson(jsonDecode(message), (data) => data);
event.onQueue((event) { event.onQueue((event) {
ref.read(ProxyPlaylistNotifier.notifier).state = event.data; ref.read(queueProvider.notifier).state = event.data;
}); });
event.onPlaying((event) { event.onPlaying((event) {
playingStreamController.add(event.data); ref.read(playingProvider.notifier).state = event.data;
}); });
event.onPosition((event) { 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) { 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; if (state.value == null) return;
state.value?.sink.add( state.value?.sink.add(
message is String ? message : (message as dynamic).toJson(), message is String ? message : (message as dynamic).toJson(),
); );
} }
void resume() { Future<void> resume() async {
emit(WebSocketResumeEvent()); emit(WebSocketResumeEvent());
} }
void pause() { Future<void> pause() async {
emit(WebSocketPauseEvent()); emit(WebSocketPauseEvent());
} }
void stop() { Future<void> stop() async {
emit(WebSocketStopEvent()); emit(WebSocketStopEvent());
} }
void jumpTo(int position) { Future<void> jumpTo(int position) async {
emit(WebSocketJumpEvent(position)); emit(WebSocketJumpEvent(position));
} }
void load(WebSocketLoadEventData data) { Future<void> load(WebSocketLoadEventData data) async {
emit(WebSocketLoadEvent(data)); emit(WebSocketLoadEvent(data));
} }
void next() { Future<void> next() async {
emit(WebSocketNextEvent()); emit(WebSocketNextEvent());
} }
void previous() { Future<void> previous() async {
emit(WebSocketPreviousEvent()); 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 = final connectProvider =

View File

@ -11,6 +11,7 @@ import 'package:shelf_router/shelf_router.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/models/logger.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -24,9 +25,11 @@ final logger = getLogger('ConnectServer');
final connectServerProvider = FutureProvider((ref) async { final connectServerProvider = FutureProvider((ref) async {
final enabled = final enabled =
ref.watch(userPreferencesProvider.select((s) => s.enableConnect)); ref.watch(userPreferencesProvider.select((s) => s.enableConnect));
final resolvedService = await ref
.watch(connectClientsProvider.selectAsync((s) => s.resolvedService));
final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier);
if (!enabled) { if (!enabled || resolvedService != null) {
return null; return null;
} }
@ -42,7 +45,7 @@ final connectServerProvider = FutureProvider((ref) async {
final subscriptions = <StreamSubscription>[]; final subscriptions = <StreamSubscription>[];
final websocket = webSocketHandler( final websocket = webSocketHandler(
(WebSocketChannel channel, String? protocol) { (WebSocketChannel channel, String? protocol) async {
ref.listen( ref.listen(
ProxyPlaylistNotifier.provider, ProxyPlaylistNotifier.provider,
(previous, next) { (previous, next) {
@ -53,6 +56,25 @@ final connectServerProvider = FutureProvider((ref) async {
fireImmediately: true, 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([ subscriptions.addAll([
audioPlayer.positionStream.listen( audioPlayer.positionStream.listen(
(position) { (position) {
@ -64,7 +86,28 @@ final connectServerProvider = FutureProvider((ref) async {
audioPlayer.playingStream.listen( audioPlayer.playingStream.listen(
(playing) { (playing) {
channel.sink.add( 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 { event.onJump((event) async {
await playbackNotifier.jumpTo(event.data); 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) { } catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace); Catcher2.reportCheckedError(e, stackTrace);
channel.sink.add(WebSocketErrorEvent(e.toString()).toJson()); channel.sink.add(WebSocketErrorEvent(e.toString()).toJson());