rocksky 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fa47af05712ec34a47fee263a1483c99053c24a9c391a0598018248ad3f73ed
4
- data.tar.gz: a5bd7548931ae1c9abfceacf1ca97c755d1c1e1b6c59be12c52f89f831553d84
3
+ metadata.gz: 84425f844d397e7187168e44115168f310f5a48269da6b1f3efc424cbf9991aa
4
+ data.tar.gz: 89e6d0d11fd1f88ec4e80498251f1d1d24cc4e77587860b7c96b0508306a3817
5
5
  SHA512:
6
- metadata.gz: c0dd411cb63a1e79b51e55ca2eb0376b4bf92903f94ee4459ec764a92071b10900e25f00adafb0ba4d25d35dd3a1ef08c152566d9d10994d074957ddeba4185d
7
- data.tar.gz: e896f5451742b2c104aab53dc6cb389cc1f9efa482b37d503a98a70bb2e9cef908cf2a2148a5f484b1aea8609c5314104678f1b9100697fe2f94e4cf5c28a7bf
6
+ metadata.gz: b48c2675d3ecedc52f6b2c7b70a728d927dd69a2564bfe16bc8b55101dfbf866e44b67957b1ad866c57819b0b66360aa637745a73c87238b0c769aa5b5b5e49a
7
+ data.tar.gz: bbc58f41ea90eda2c8878cde5e6c182a364a5d8de5f4056ab5e6baad5178c67756cf2f02da5343a2b1a679d83839c02b0c1787de64ec6119a42c979abc9a9e2a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1
4
+
5
+ - Fix a segfault when tearing down a `RemotePlayer` / `RemoteController`.
6
+ `#close` freed the native handle while the `#listen` background thread was
7
+ still blocked inside `#next_command` / `#next_event` (a use-after-free). It now
8
+ stops the background task and **joins** the listen thread — so the poll has
9
+ returned — before freeing. `#next_command` / `#next_event` also no-op once the
10
+ handle is released.
11
+
3
12
  ## 0.3.0
4
13
 
5
14
  - `feed.get_stories` now accepts optional `feed:` (at-uri) and `following:`
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "repo": "tsirysndr/rocksky",
3
- "tag": "bindings-v0.5.1",
3
+ "tag": "bindings-v0.6.0",
4
4
  "checksums": {
5
- "aarch64-apple-darwin": "2f8ea8e242f2a899b081e44d139d363f79e943427c639e73b92e813a3232752d",
6
- "aarch64-linux-gnu": "eba5b2f93e19b77d1f2170baa6161f0bad33663cf5d4bbf8f4977ade3c60d286",
7
- "x86_64-apple-darwin": "8e6c25c3cad1e45a241745c503bcd505bb26f6c091243cf90ece0f49a9349126",
8
- "x86_64-linux-gnu": "79bbe4cb83535e5ed32b538809ba030bc10214befc9d4fce76540f62568c81c1",
9
- "x86_64-unknown-freebsd": "59b4e07b6b12931538a2ae77f6ca2e0b542a3aebd23544f6efcf22c429ba088f",
10
- "x86_64-unknown-netbsd": "808ec11ba8cb0e168f399f9f61a8993c8dedfbced51ac57a9922e16e31f1a511"
5
+ "aarch64-apple-darwin": "e2de89af96e1330427898524254ddb57728453947b607c4dd29fb8ed7c513b27",
6
+ "aarch64-linux-gnu": "37b8f5ec3812cb15f7eeb75beb44ffe857cca95c11064d1e781eaff6edf7c14d",
7
+ "x86_64-apple-darwin": "7a550b51fc75e1e4d7c5393423f2920dda980ea8f026c9bde8edd926d18d3795",
8
+ "x86_64-linux-gnu": "683e9c14579041c77fa69b53f73b43a44432719c608d7af74f950c9263c5c53b",
9
+ "x86_64-unknown-freebsd": "4ecf5a61000b46111aef2c66e241c97e712c8ef6af386a01afddfe2c05136893",
10
+ "x86_64-unknown-netbsd": "0c619e60f6cf95c42bbe61adb26be8550024c8cbea08be6de9fb3127db627900"
11
11
  }
12
12
  }
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rocksky
4
+ # RemotePlayer — build a Rocksky-controllable player in a few lines.
5
+ #
6
+ # It speaks the remote-control WebSocket protocol (see remote-ws/PROTOCOL.md)
7
+ # over the shared Rust core: it registers as a device, advertises what you're
8
+ # playing (now-playing / status / queue), and invokes your handlers when a
9
+ # miniplayer sends a command (play/pause/next/previous/seek/enqueue/queue
10
+ # actions). Heartbeat, reconnect, and the device-id handshake are handled for
11
+ # you inside the core.
12
+ #
13
+ # Commands arrive over a blocking poll (#next_command). Register handlers with
14
+ # #on and call #listen to spawn a background thread that dispatches them, so you
15
+ # never write the loop yourself.
16
+ #
17
+ # player = Rocksky::RemotePlayer.connect(token, "My Player")
18
+ # player.on(:play) { engine.play }
19
+ # player.on(:pause) { engine.pause }
20
+ # player.on(:seek) { |cmd| engine.seek(cmd[:position]) }
21
+ # player.listen
22
+ # # …then, as your engine plays:
23
+ # player.set_now_playing(title: "Chaser", artist: "Calibro 35",
24
+ # durationMs: 182_320, elapsedMs: 0, isPlaying: true)
25
+ # player.set_status("playing")
26
+ # player.set_queue(items, 0)
27
+ #
28
+ # Records (now-playing / queue items) are passed as Hashes with camelCase keys
29
+ # (title, artist, album, albumArtist, albumArt, durationMs, elapsedMs,
30
+ # isPlaying / songUri, albumUri, trackNumber), matching the wire record shape.
31
+ class RemotePlayer
32
+ # Connect and register a controllable player in the background. +token+ is a
33
+ # Rocksky access token (JWT); +name+ is the device-picker label; +url+
34
+ # defaults to the public endpoint when omitted.
35
+ def self.connect(token, name, url: nil)
36
+ ptr = C.rocksky_remote_player_connect(token.to_s, name.to_s, url.to_s)
37
+ raise Error, (Rocksky.take_string(C.rocksky_last_error()) || "connect failed") if ptr.null?
38
+
39
+ new(ptr)
40
+ end
41
+
42
+ def initialize(ptr)
43
+ @ptr = ptr
44
+ @handlers = {}
45
+ @thread = nil
46
+ end
47
+
48
+ # Register a handler for a command action, one of +:play+, +:pause+, +:next+,
49
+ # +:previous+, +:seek+, +:queue_jump+, +:queue_remove+, +:enqueue+. The block
50
+ # receives the full symbol-keyed command Hash (e.g. +{ action: "seek",
51
+ # position: 42000 }+). Returns +self+ for chaining.
52
+ def on(action, &block)
53
+ @handlers[action.to_sym] = block
54
+ self
55
+ end
56
+
57
+ # Block until the next controller command, returned as a symbol-keyed Hash
58
+ # (e.g. +{ action: "play" }+), or +nil+ once disconnected.
59
+ def next_command
60
+ return nil if @ptr.null?
61
+
62
+ cmd = Rocksky.unwrap(C.rocksky_remote_player_next_command(@ptr))
63
+ cmd && Rocksky.symbolize(cmd)
64
+ end
65
+
66
+ # Spawn a background thread that polls #next_command and dispatches each
67
+ # command to its registered handler until the player disconnects. Returns the
68
+ # Thread.
69
+ def listen
70
+ @thread ||= Thread.new do
71
+ loop do
72
+ cmd = next_command
73
+ break if cmd.nil?
74
+
75
+ dispatch(cmd)
76
+ end
77
+ end
78
+ end
79
+
80
+ # Advertise the currently-playing track. Call whenever it changes, and
81
+ # periodically (~every 1–4s) with a fresh +elapsedMs+ so controllers show
82
+ # smooth progress. +track+ is a camelCase Hash.
83
+ def set_now_playing(track)
84
+ Rocksky.unwrap(C.rocksky_remote_player_set_now_playing(@ptr, JSON.generate(track)))
85
+ end
86
+
87
+ # Advertise transport state: "playing", "paused", or "stopped".
88
+ def set_status(status)
89
+ Rocksky.unwrap(C.rocksky_remote_player_set_status(@ptr, status.to_s))
90
+ end
91
+
92
+ # Advertise the playback queue (+items+ is an array of camelCase queue-item
93
+ # Hashes) and the active +index+.
94
+ def set_queue(items, index)
95
+ Rocksky.unwrap(C.rocksky_remote_player_set_queue(@ptr, JSON.generate(items), index))
96
+ end
97
+
98
+ # Disconnect and stop the background task. #next_command then returns nil,
99
+ # which ends the #listen thread. The handle stays valid until #close.
100
+ def disconnect
101
+ Rocksky.unwrap(C.rocksky_remote_player_disconnect(@ptr))
102
+ end
103
+
104
+ # Release the native handle. The player is unusable afterwards.
105
+ def close
106
+ return if @ptr.null?
107
+
108
+ # Stop the background task so a blocked #next_command returns nil and the
109
+ # #listen loop exits, then WAIT for that thread to leave the native call
110
+ # before freeing — freeing the handle while a poll is in flight is a
111
+ # use-after-free (segfault).
112
+ C.rocksky_remote_player_disconnect(@ptr)
113
+ @thread&.join unless @thread == Thread.current
114
+ @thread = nil
115
+ C.rocksky_remote_player_free(@ptr)
116
+ @ptr = Fiddle::Pointer.new(0)
117
+ end
118
+
119
+ private
120
+
121
+ def dispatch(cmd)
122
+ handler = @handlers[cmd[:action]&.to_sym]
123
+ handler&.call(cmd)
124
+ end
125
+ end
126
+
127
+ # RemoteController — build a Rocksky remote UI in a few lines.
128
+ #
129
+ # The other half of the remote-control WebSocket protocol: where
130
+ # {RemotePlayer} is a controllable player, a controller lists the user's
131
+ # players, observes what each is playing (now-playing / status / queue), picks
132
+ # the primary device, and sends commands. Heartbeat, reconnect, and the
133
+ # register handshake are handled for you inside the core.
134
+ #
135
+ # Events arrive over a blocking poll (#next_event). Register handlers with #on
136
+ # and call #listen to spawn a background dispatch thread.
137
+ #
138
+ # controller = Rocksky::RemoteController.connect(token, "My Controller")
139
+ # controller.on(:devices) { |e| render(e[:devices]) }
140
+ # controller.on(:now_playing) { |e| show_track(e[:deviceId], e[:track]) }
141
+ # controller.listen
142
+ # # …then drive a device:
143
+ # controller.set_primary(device_id)
144
+ # controller.pause(device_id)
145
+ # controller.seek(device_id, 42_000)
146
+ #
147
+ # A +nil+ / omitted +target+ broadcasts a command to all of the user's devices.
148
+ class RemoteController
149
+ # Connect and register a controller in the background. +url+ defaults to the
150
+ # public endpoint when omitted.
151
+ def self.connect(token, name, url: nil)
152
+ ptr = C.rocksky_remote_controller_connect(token.to_s, name.to_s, url.to_s)
153
+ raise Error, (Rocksky.take_string(C.rocksky_last_error()) || "connect failed") if ptr.null?
154
+
155
+ new(ptr)
156
+ end
157
+
158
+ def initialize(ptr)
159
+ @ptr = ptr
160
+ @handlers = {}
161
+ @thread = nil
162
+ end
163
+
164
+ # Register a handler for a server event type, one of +:devices+,
165
+ # +:device_registered+, +:device_unregistered+, +:primary_changed+,
166
+ # +:now_playing+, +:status+, +:queue+. The block receives the full
167
+ # symbol-keyed event Hash. Returns +self+ for chaining.
168
+ def on(event_type, &block)
169
+ @handlers[event_type.to_sym] = block
170
+ self
171
+ end
172
+
173
+ # Block until the next update, returned as a symbol-keyed Hash (each carries a
174
+ # +:type+), or +nil+ once disconnected.
175
+ def next_event
176
+ return nil if @ptr.null?
177
+
178
+ ev = Rocksky.unwrap(C.rocksky_remote_controller_next_event(@ptr))
179
+ ev && Rocksky.symbolize(ev)
180
+ end
181
+
182
+ # Spawn a background thread that polls #next_event and dispatches each event
183
+ # to its registered handler until the controller disconnects. Returns the
184
+ # Thread.
185
+ def listen
186
+ @thread ||= Thread.new do
187
+ loop do
188
+ ev = next_event
189
+ break if ev.nil?
190
+
191
+ dispatch(ev)
192
+ end
193
+ end
194
+ end
195
+
196
+ # Choose the primary (scrobble/profile) device.
197
+ def set_primary(device_id)
198
+ Rocksky.unwrap(C.rocksky_remote_controller_set_primary(@ptr, device_id.to_s))
199
+ end
200
+
201
+ # +play+. +target+ is a device id, or omit/nil to broadcast to all devices.
202
+ def play(target = nil)
203
+ command("play", target)
204
+ end
205
+
206
+ def pause(target = nil)
207
+ command("pause", target)
208
+ end
209
+
210
+ def next(target = nil)
211
+ command("next", target)
212
+ end
213
+
214
+ def previous(target = nil)
215
+ command("previous", target)
216
+ end
217
+
218
+ # Send a simple command ("play" | "pause" | "next" | "previous").
219
+ def command(action, target = nil)
220
+ Rocksky.unwrap(C.rocksky_remote_controller_command(@ptr, action.to_s, target.to_s))
221
+ end
222
+
223
+ # Seek +target+ to +position_ms+ (nil target = broadcast).
224
+ def seek(target, position_ms)
225
+ Rocksky.unwrap(C.rocksky_remote_controller_seek(@ptr, target.to_s, position_ms))
226
+ end
227
+
228
+ # Jump to +index+ in +target+'s queue (nil target = broadcast).
229
+ def queue_jump(target, index)
230
+ Rocksky.unwrap(C.rocksky_remote_controller_queue_jump(@ptr, target.to_s, index))
231
+ end
232
+
233
+ # Remove queue item +index+ on +target+ (nil target = broadcast).
234
+ def queue_remove(target, index)
235
+ Rocksky.unwrap(C.rocksky_remote_controller_queue_remove(@ptr, target.to_s, index))
236
+ end
237
+
238
+ # Enqueue +tracks+ (array of camelCase queue-item Hashes) on +target+. +mode+
239
+ # is "now" | "next" | "last"; nil target = broadcast.
240
+ def enqueue(target, tracks, mode: "now", shuffle: false, start_index: 0)
241
+ Rocksky.unwrap(C.rocksky_remote_controller_enqueue(
242
+ @ptr, target.to_s, JSON.generate(tracks), mode.to_s, shuffle ? 1 : 0, start_index
243
+ ))
244
+ end
245
+
246
+ # Disconnect and stop the background task. #next_event then returns nil, which
247
+ # ends the #listen thread. The handle stays valid until #close.
248
+ def disconnect
249
+ Rocksky.unwrap(C.rocksky_remote_controller_disconnect(@ptr))
250
+ end
251
+
252
+ # Release the native handle. The controller is unusable afterwards.
253
+ def close
254
+ return if @ptr.null?
255
+
256
+ # Stop the background task so a blocked #next_event returns nil and the
257
+ # #listen loop exits, then WAIT for that thread to leave the native call
258
+ # before freeing — freeing the handle while a poll is in flight is a
259
+ # use-after-free (segfault).
260
+ C.rocksky_remote_controller_disconnect(@ptr)
261
+ @thread&.join unless @thread == Thread.current
262
+ @thread = nil
263
+ C.rocksky_remote_controller_free(@ptr)
264
+ @ptr = Fiddle::Pointer.new(0)
265
+ end
266
+
267
+ private
268
+
269
+ def dispatch(event)
270
+ handler = @handlers[event[:type]&.to_sym]
271
+ handler&.call(event)
272
+ end
273
+ end
274
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rocksky
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.1"
5
5
  end
data/lib/rocksky.rb CHANGED
@@ -47,7 +47,26 @@ module Rocksky
47
47
  "char* rocksky_agent_shout(void*, const char*, const char*, const char*)",
48
48
  "char* rocksky_agent_shout_with_gif(void*, const char*, const char*, const char*, const char*)",
49
49
  "char* rocksky_agent_reply_shout_with_gif(void*, const char*, const char*, const char*, const char*, const char*, const char*)",
50
- "char* rocksky_agent_refresh_session(void*)"
50
+ "char* rocksky_agent_refresh_session(void*)",
51
+ # ---- remote player (a controllable device) ----
52
+ "void* rocksky_remote_player_connect(const char*, const char*, const char*)",
53
+ "char* rocksky_remote_player_next_command(void*)",
54
+ "char* rocksky_remote_player_set_now_playing(void*, const char*)",
55
+ "char* rocksky_remote_player_set_status(void*, const char*)",
56
+ "char* rocksky_remote_player_set_queue(void*, const char*, unsigned int)",
57
+ "char* rocksky_remote_player_disconnect(void*)",
58
+ "void rocksky_remote_player_free(void*)",
59
+ # ---- remote controller (a remote UI: drive & observe players) ----
60
+ "void* rocksky_remote_controller_connect(const char*, const char*, const char*)",
61
+ "char* rocksky_remote_controller_next_event(void*)",
62
+ "char* rocksky_remote_controller_set_primary(void*, const char*)",
63
+ "char* rocksky_remote_controller_command(void*, const char*, const char*)",
64
+ "char* rocksky_remote_controller_seek(void*, const char*, unsigned long long)",
65
+ "char* rocksky_remote_controller_queue_jump(void*, const char*, unsigned int)",
66
+ "char* rocksky_remote_controller_queue_remove(void*, const char*, unsigned int)",
67
+ "char* rocksky_remote_controller_enqueue(void*, const char*, const char*, const char*, char, unsigned int)",
68
+ "char* rocksky_remote_controller_disconnect(void*)",
69
+ "void rocksky_remote_controller_free(void*)"
51
70
  ].each { |sig| extern sig }
52
71
  end
53
72
  private_constant :C
@@ -76,6 +95,20 @@ module Rocksky
76
95
  parsed["ok"]
77
96
  end
78
97
 
98
+ # Recursively convert Hash string keys to symbols (Arrays walked too). Used by
99
+ # the remote-control glue so commands/events reach handlers as symbol-keyed
100
+ # hashes (e.g. cmd[:action], event[:type]).
101
+ def self.symbolize(obj)
102
+ case obj
103
+ when Hash
104
+ obj.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = symbolize(v) }
105
+ when Array
106
+ obj.map { |e| symbolize(e) }
107
+ else
108
+ obj
109
+ end
110
+ end
111
+
79
112
  # ---- reads (unauthenticated; base: overrides the AppView URL) ----
80
113
 
81
114
  def self.profile(actor, base: nil)
@@ -269,3 +302,4 @@ module Rocksky
269
302
  end
270
303
 
271
304
  require_relative "rocksky/library"
305
+ require_relative "rocksky/remote"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rocksky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rocksky
@@ -83,6 +83,7 @@ files:
83
83
  - lib/rocksky/library.rb
84
84
  - lib/rocksky/manifest.json
85
85
  - lib/rocksky/native.rb
86
+ - lib/rocksky/remote.rb
86
87
  - lib/rocksky/version.rb
87
88
  - rocksky.gemspec
88
89
  homepage: https://github.com/tsirysndr/rocksky