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/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(
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
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,
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user