socket.js 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018
  1. // Tools for hosting an MTUI party over a socket server. Comparable in idea to
  2. // telnet.js, but for interfacing over commands rather than hosting all client
  3. // UIs on one server. The intent of the code in this file is to allow clients
  4. // to connect and interface with each other, while still running all processes
  5. // involved in mtui on their own machines -- so mtui will download and play
  6. // music using each connected machine's own internet connection and speakers.
  7. // TODO: Option to display listing items which aren't available on all
  8. // connected devices.
  9. //
  10. // TODO: While having a canonical backend is useful for maintaining a baseline
  11. // playback position and queue/library with which to sync clients, it probably
  12. // shouldn't be necessary to have an actual JS reference to that backend.
  13. // Making communication with the canonical backend work over socket (in as much
  14. // as possible the same way we do current socket communication) means the
  15. // server can be run on a remote host without requiring access to the music
  16. // library from there. This would be handy for people with a VPN with its own
  17. // hostname and firewall protections!
  18. // single quotes & no semicolons time babey
  19. import EventEmitter from 'node:events'
  20. import net from 'node:net'
  21. import shortid from 'shortid'
  22. import {
  23. getTimeStringsFromSec,
  24. parseWithoutPrototype,
  25. silenceEvents,
  26. } from './general-util.js'
  27. import {
  28. parentSymbol,
  29. updateGroupFormat,
  30. updateTrackFormat,
  31. isTrack,
  32. isGroup,
  33. } from './playlist-utils.js'
  34. import {
  35. restoreBackend,
  36. restoreNewItem,
  37. saveBackend,
  38. saveItemReference,
  39. updateRestoredTracksUsingPlaylists,
  40. } from './serialized-backend.js'
  41. // This is expected to be the same across both the client and the server.
  42. // There will probably be inconsistencies between sender clients and receiving
  43. // clients / the server otherwise.
  44. const DEFAULT_NICKNAME = '(Unnamed)'
  45. export const originalSymbol = Symbol('Original item')
  46. function serializePartySource(item) {
  47. // Turn an item into a sanitized, compact format for sharing with the server
  48. // and other sockets in the party.
  49. //
  50. // TODO: We'll probably need to assign a unique ID to the root item, since
  51. // otherwise we don't have a way to target it to un-share it.
  52. if (isGroup(item)) {
  53. return [item.name, ...item.items.map(serializePartySource).filter(Boolean)]
  54. } else if (isTrack(item)) {
  55. return item.name
  56. } else {
  57. return null
  58. }
  59. }
  60. function deserializePartySource(source, parent = null) {
  61. // Reconstruct a party source into the ordinary group/track format.
  62. const recursive = source => {
  63. if (Array.isArray(source)) {
  64. return {name: source[0], items: source.slice(1).map(recursive).filter(Boolean)}
  65. } else if (typeof source === 'string') {
  66. return {name: source, downloaderArg: '-'}
  67. } else {
  68. return null
  69. }
  70. }
  71. const top = recursive(source)
  72. const item = (isGroup(top)
  73. ? updateGroupFormat(top)
  74. : updateTrackFormat(top))
  75. if (parent) {
  76. item[parentSymbol] = parent
  77. }
  78. return item
  79. }
  80. function serializeCommandToData(command) {
  81. // Turn a command into a string/buffer that can be sent over a socket.
  82. return JSON.stringify(command)
  83. }
  84. function deserializeDataToCommand(data) {
  85. // Turn data received from a socket into a command that can be processed as
  86. // an action to apply to the mtui backend.
  87. return parseWithoutPrototype(data)
  88. }
  89. function namePartySources(nickname) {
  90. return `Party Sources - ${nickname}`
  91. }
  92. function isItemRef(ref) {
  93. if (ref === null || typeof ref !== 'object') {
  94. return false
  95. }
  96. // List of true/false/null. False means *invalid* reference data; null
  97. // means *nonpresent* reference data. True means present and valid.
  98. const conditionChecks = [
  99. 'name' in ref ? typeof ref.name === 'string' : null,
  100. 'path' in ref ? Array.isArray(ref.path) && ref.path.every(n => typeof n === 'string') : null,
  101. 'downloaderArg' in ref ? (
  102. !('items' in ref) &&
  103. typeof ref.downloaderArg === 'string'
  104. ) : null,
  105. 'items' in ref ? (
  106. !('downloaderArg' in ref) &&
  107. Array.isArray(ref.items) &&
  108. ref.items.every(isItemRef)
  109. ) : null
  110. ]
  111. if (conditionChecks.includes(false)) {
  112. return false
  113. }
  114. if (!conditionChecks.includes(true)) {
  115. return false
  116. }
  117. return true
  118. }
  119. function validateCommand(command) {
  120. // TODO: Could be used to validate "against" a backend, but for now it just
  121. // checks data types.
  122. if (typeof command !== 'object') {
  123. return false
  124. }
  125. if (!['server', 'client'].includes(command.sender)) {
  126. return false
  127. }
  128. switch (command.sender) {
  129. case 'server':
  130. switch (command.code) {
  131. case 'initialize party':
  132. return (
  133. typeof command.backend === 'object' &&
  134. typeof command.socketInfo === 'object' &&
  135. Object.values(command.socketInfo).every(info => (
  136. typeof info.nickname === 'string' &&
  137. Array.isArray(info.sharedSources)
  138. ))
  139. )
  140. case 'set socket id':
  141. return typeof command.socketId === 'string'
  142. }
  143. // No break here; servers can send commands which typically come from
  144. // clients too.
  145. case 'client':
  146. switch (command.code) {
  147. case 'announce join':
  148. return true
  149. case 'clear queue':
  150. return typeof command.queuePlayer === 'string'
  151. case 'clear queue past':
  152. case 'clear queue up to':
  153. return (
  154. typeof command.queuePlayer === 'string' &&
  155. isItemRef(command.track)
  156. )
  157. case 'distribute queue':
  158. return (
  159. typeof command.queuePlayer === 'string' &&
  160. isItemRef(command.topItem) &&
  161. (!command.opts || typeof command.opts === 'object' && (
  162. (
  163. !command.opts.how ||
  164. ['evenly', 'randomly'].includes(command.opts.how)
  165. ) &&
  166. (
  167. !command.opts.rangeEnd ||
  168. ['end-of-queue'].includes(command.opts.rangeEnd) ||
  169. typeof command.opts.rangeEnd === 'number'
  170. )
  171. ))
  172. )
  173. case 'play':
  174. return (
  175. typeof command.queuePlayer === 'string' &&
  176. isItemRef(command.track)
  177. )
  178. case 'queue':
  179. return (
  180. typeof command.queuePlayer === 'string' &&
  181. isItemRef(command.topItem) &&
  182. (
  183. isItemRef(command.afterItem) ||
  184. [null, 'FRONT'].includes(command.afterItem)
  185. ) &&
  186. (!command.opts || typeof command.opts === 'object' && (
  187. (
  188. !command.opts.movePlayingTrack ||
  189. typeof command.opts.movePlayingTrack === 'boolean'
  190. )
  191. ))
  192. )
  193. case 'restore queue':
  194. return (
  195. typeof command.queuePlayer === 'string' &&
  196. Array.isArray(command.tracks) &&
  197. command.tracks.every(track => isItemRef(track)) &&
  198. ['shuffle'].includes(command.why)
  199. )
  200. case 'seek to':
  201. return (
  202. typeof command.queuePlayer === 'string' &&
  203. typeof command.time === 'number'
  204. )
  205. case 'set nickname':
  206. return (
  207. typeof command.nickname === 'string' &&
  208. typeof command.oldNickname === 'string' &&
  209. command.nickname.length >= 1 &&
  210. command.nickname.length <= 12
  211. )
  212. case 'set pause':
  213. return (
  214. typeof command.queuePlayer === 'string' &&
  215. typeof command.paused === 'boolean' &&
  216. (
  217. typeof command.startingTrack === 'boolean' &&
  218. command.sender === 'server'
  219. ) || !command.startingTrack
  220. )
  221. case 'added queue player':
  222. return (
  223. typeof command.id === 'string'
  224. )
  225. case 'share with party':
  226. return (
  227. typeof command.item === 'string' ||
  228. Array.isArray(command.item)
  229. )
  230. case 'status':
  231. return (
  232. command.status === 'done playing' ||
  233. (
  234. command.status === 'ready to resume' &&
  235. typeof command.queuePlayer === 'string'
  236. ) ||
  237. command.status === 'sync playback'
  238. )
  239. case 'stop playing':
  240. return typeof command.queuePlayer === 'string'
  241. case 'unqueue':
  242. return (
  243. typeof command.queuePlayer === 'string' &&
  244. isItemRef(command.topItem)
  245. )
  246. }
  247. break
  248. }
  249. return false
  250. }
  251. function perLine(handleLine) {
  252. // Wrapper function to run a callback for each line provided to the wrapped
  253. // callback. Maintains a "partial" variable so that a line may be broken up
  254. // into multiple chunks before it is sent. Also supports handling multiple
  255. // lines (including the conclusion to a previously received partial line)
  256. // being received at once.
  257. let partial = ''
  258. return data => {
  259. const text = data.toString()
  260. const lines = text.split('\n')
  261. if (lines.length === 1) {
  262. partial += text
  263. } else {
  264. handleLine(partial + lines[0])
  265. for (const line of lines.slice(1, -1)) {
  266. handleLine(line)
  267. }
  268. partial = lines[lines.length - 1]
  269. }
  270. }
  271. }
  272. export function makeSocketServer() {
  273. // The socket server has two functions: to maintain a "canonical" backend
  274. // and synchronize newly connected clients with the relevent data in this
  275. // backend, and to receive command data from clients and relay this to
  276. // other clients.
  277. //
  278. // makeSocketServer doesn't actually start the server listening on a port;
  279. // that's the responsibility of the caller (use server.listen()).
  280. const server = new net.Server()
  281. const socketMap = Object.create(null)
  282. // Keeps track of details to share with newly joining sockets for
  283. // synchronization.
  284. const socketInfoMap = Object.create(null)
  285. server.canonicalBackend = null
  286. // <variable> -> queue player id -> array: socket
  287. const readyToResume = Object.create(null)
  288. const donePlaying = Object.create(null)
  289. server.on('connection', socket => {
  290. const socketId = shortid.generate()
  291. const socketInfo = {
  292. hasAnnouncedJoin: false,
  293. nickname: DEFAULT_NICKNAME,
  294. // Unlike in client code, this isn't an array of actual playlist items;
  295. // rather, it's the intermediary format used when transferring between
  296. // client and server.
  297. sharedSources: []
  298. }
  299. socketMap[socketId] = socket
  300. socketInfoMap[socketId] = socketInfo
  301. socket.on('close', () => {
  302. if (socketId in socketMap) {
  303. delete socketMap[socketId]
  304. delete socketInfoMap[socketId]
  305. }
  306. })
  307. socket.on('data', perLine(line => {
  308. // Parse data as a command and validate it. If invalid, drop this data.
  309. let command
  310. try {
  311. command = deserializeDataToCommand(line)
  312. } catch (error) {
  313. return
  314. }
  315. command.sender = 'client'
  316. command.senderSocketId = socketId
  317. command.senderNickname = socketInfo.nickname
  318. if (!validateCommand(command)) {
  319. return
  320. }
  321. // If the socket hasn't announced its joining yet, it only has access to
  322. // a few commands.
  323. if (!socketInfo.hasAnnouncedJoin) {
  324. if (![
  325. 'announce join',
  326. 'set nickname'
  327. ].includes(command.code)) {
  328. return
  329. }
  330. }
  331. // If it's a status command, respond appropriately, and return so that it
  332. // is not relayed.
  333. if (command.code === 'status') {
  334. switch (command.status) {
  335. case 'done playing': {
  336. const doneSockets = donePlaying[command.queuePlayer]
  337. if (doneSockets && !doneSockets.includes(socketId)) {
  338. doneSockets.push(socketId)
  339. if (doneSockets.length === Object.keys(socketMap).length) {
  340. // determine next track
  341. for (const socket of Object.values(socketMap)) {
  342. // play next track
  343. }
  344. delete donePlaying[command.queuePlayer]
  345. }
  346. }
  347. break
  348. }
  349. case 'ready to resume': {
  350. const readySockets = readyToResume[command.queuePlayer]
  351. if (readySockets && !readySockets.includes(socketId)) {
  352. readySockets.push(socketId)
  353. if (readySockets.length === Object.keys(socketMap).length) {
  354. for (const socket of Object.values(socketMap)) {
  355. socket.write(serializeCommandToData({
  356. sender: 'server',
  357. code: 'set pause',
  358. queuePlayer: command.queuePlayer,
  359. startingTrack: true,
  360. paused: false
  361. }) + '\n')
  362. donePlaying[command.queuePlayer] = []
  363. }
  364. delete readyToResume[command.queuePlayer]
  365. }
  366. }
  367. break
  368. }
  369. case 'sync playback':
  370. for (const QP of server.canonicalBackend.queuePlayers) {
  371. if (QP.timeData) {
  372. socket.write(serializeCommandToData({
  373. sender: 'server',
  374. code: 'seek to',
  375. queuePlayer: QP.id,
  376. time: QP.timeData.curSecTotal
  377. }) + '\n')
  378. socket.write(serializeCommandToData({
  379. sender: 'server',
  380. code: 'set pause',
  381. queuePlayer: QP.id,
  382. startingTrack: true,
  383. paused: QP.player.isPaused
  384. }) + '\n')
  385. }
  386. }
  387. break
  388. }
  389. return
  390. }
  391. // If it's a 'play' command, set up a new readyToResume array.
  392. if (command.code === 'play') {
  393. readyToResume[command.queuePlayer] = []
  394. }
  395. // If it's a 'set nickname' command, save the nickname.
  396. // Also attach the old nickname for display in log messages.
  397. if (command.code === 'set nickname') {
  398. command.oldNickname = socketInfo.nickname
  399. command.senderNickname = socketInfo.nickname
  400. socketInfo.nickname = command.nickname
  401. }
  402. // If it's a 'share with party' command, keep track of the item being
  403. // shared, so we can synchronize newly joining sockets with it.
  404. if (command.code === 'share with party') {
  405. const { sharedSources } = socketInfoMap[socketId]
  406. sharedSources.push(command.item)
  407. }
  408. // If it's an 'announce join' command, mark the variable for this!
  409. if (command.code === 'announce join') {
  410. socketInfo.hasAnnouncedJoin = true;
  411. }
  412. // If the socket hasn't announced its joining yet, don't relay the
  413. // command. (Since hasAnnouncedJoin gets set above, 'announce join'
  414. // will pass this condition.)
  415. if (!socketInfo.hasAnnouncedJoin) {
  416. return
  417. }
  418. // Relay the command to client sockets besides the sender.
  419. const otherSockets = Object.values(socketMap).filter(s => s !== socket)
  420. for (const socket of otherSockets) {
  421. socket.write(serializeCommandToData(command) + '\n')
  422. }
  423. }))
  424. const savedBackend = saveBackend(server.canonicalBackend)
  425. for (const qpData of savedBackend.queuePlayers) {
  426. if (qpData.playerInfo) {
  427. qpData.playerInfo.isPaused = true
  428. }
  429. }
  430. socket.write(serializeCommandToData({
  431. sender: 'server',
  432. code: 'set socket id',
  433. socketId
  434. }) + '\n')
  435. socket.write(serializeCommandToData({
  436. sender: 'server',
  437. code: 'initialize party',
  438. backend: savedBackend,
  439. socketInfo: socketInfoMap
  440. }) + '\n')
  441. })
  442. return server
  443. }
  444. export function makeSocketClient() {
  445. // The socket client connects to a server and sends/receives commands to/from
  446. // that server. This doesn't actually connect the socket to a port/host; that
  447. // is the caller's responsibility (use client.socket.connect()).
  448. const client = new EventEmitter()
  449. client.socket = new net.Socket()
  450. client.nickname = DEFAULT_NICKNAME
  451. client.socketId = null // Will be received from server.
  452. client.sendCommand = function(command) {
  453. const data = serializeCommandToData(command)
  454. client.socket.write(data + '\n')
  455. client.emit('sent command', command)
  456. }
  457. client.socket.on('data', perLine(line => {
  458. // Same sort of "guarding" deserialization/validation as in the server
  459. // code, because it's possible the client and server backends mismatch.
  460. let command
  461. try {
  462. command = deserializeDataToCommand(line)
  463. } catch (error) {
  464. return
  465. }
  466. if (!validateCommand(command)) {
  467. return
  468. }
  469. client.emit('command', command)
  470. }))
  471. return client
  472. }
  473. export function attachBackendToSocketClient(backend, client) {
  474. // All actual logic for instances of the mtui backend interacting with each
  475. // other through commands lives here.
  476. let hasAnnouncedJoin = false
  477. const sharedSources = {
  478. name: namePartySources(client.nickname),
  479. isPartySources: true,
  480. items: []
  481. }
  482. const socketInfoMap = Object.create(null)
  483. const getPlaylistSources = () =>
  484. sharedSources.items.map(item => item[originalSymbol])
  485. backend.setHasAnnouncedJoin(false)
  486. backend.setAlwaysStartPaused(true)
  487. backend.setWaitWhenDonePlaying(true)
  488. function logCommand(command) {
  489. const nickToMessage = nickname => `\x1b[32;1m${nickname}\x1b[0m`
  490. const itemToMessage = item => `\x1b[32m"${item.name}"\x1b[0m`
  491. let senderNickname = command.sender === 'server' ? 'the server' : command.senderNickname
  492. // TODO: This should use a unique sender ID, provided by the server and
  493. // corresponding to the socket. This could be implemented into the UI!
  494. // But also, right now users can totally pretend to be the server by...
  495. // setting their nickname to "the server", which is silly.
  496. const sender = senderNickname
  497. let actionmsg = `sent ${command.code} (no action message specified)`
  498. let code = command.code
  499. let mayCombine = false
  500. let isVerbose = false
  501. switch (command.code) {
  502. case 'announce join':
  503. actionmsg = `joined the party`
  504. break
  505. case 'clear queue':
  506. actionmsg = 'cleared the queue'
  507. break
  508. case 'clear queue past':
  509. actionmsg = `cleared the queue past ${itemToMessage(command.track)}`
  510. break
  511. case 'clear queue up to':
  512. actionmsg = `cleared the queue up to ${itemToMessage(command.track)}`
  513. break
  514. case 'distribute queue':
  515. actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}`
  516. break
  517. case 'initialize party':
  518. return
  519. case 'play':
  520. actionmsg = `started playing ${itemToMessage(command.track)}`
  521. break
  522. case 'queue': {
  523. let afterMessage = ''
  524. if (isItemRef(command.afterItem)) {
  525. afterMessage = ` after ${itemToMessage(command.afterItem)}`
  526. } else if (command.afterItem === 'FRONT') {
  527. afterMessage = ` at the front of the queue`
  528. }
  529. actionmsg = `queued ${itemToMessage(command.topItem)}` + afterMessage
  530. break
  531. }
  532. case 'restore queue':
  533. if (command.why === 'shuffle') {
  534. actionmsg = 'shuffled the queue'
  535. }
  536. break
  537. case 'share with party':
  538. // TODO: This isn't an outrageously expensive operation, but it still
  539. // seems a little unnecessary to deserialize it here if we also do that
  540. // when actually processing the source?
  541. actionmsg = `shared ${itemToMessage(deserializePartySource(command.item))} with the party`
  542. break
  543. case 'seek to':
  544. // TODO: the second value here should be the duration of the track
  545. // (this will make values like 0:0x:yy / 1:xx:yy appear correctly)
  546. actionmsg = `seeked to ${getTimeStringsFromSec(command.time, command.time).timeDone}`
  547. mayCombine = true
  548. break
  549. case 'set nickname':
  550. actionmsg = `updated their nickname (from ${nickToMessage(command.oldNickname)})`
  551. senderNickname = command.nickname
  552. break
  553. case 'set socket id':
  554. return
  555. case 'set pause':
  556. if (command.paused) {
  557. actionmsg = 'paused the player'
  558. } else {
  559. actionmsg = 'resumed the player'
  560. }
  561. break
  562. case 'stop playing':
  563. actionmsg = 'stopped the player'
  564. break
  565. case 'unqueue':
  566. actionmsg = `removed ${itemToMessage(command.topItem)} from the queue`
  567. break
  568. case 'added queue player':
  569. actionmsg = `created a new playback queue`
  570. break
  571. case 'status':
  572. isVerbose = true
  573. switch (command.status) {
  574. case 'ready to resume':
  575. actionmsg = `is ready to play!`
  576. break
  577. case 'done playing':
  578. actionmsg = `has finished playing`
  579. break
  580. case 'sync playback':
  581. actionmsg = `synced playback with the server`
  582. break
  583. default:
  584. actionmsg = `sent status "${command.status}"`
  585. break
  586. }
  587. break
  588. }
  589. const text = `${nickToMessage(senderNickname)} ${actionmsg}`
  590. backend.showLogMessage({
  591. text,
  592. code,
  593. sender,
  594. mayCombine,
  595. isVerbose
  596. })
  597. }
  598. client.on('sent command', command => {
  599. command.senderNickname = client.nickname
  600. logCommand(command)
  601. })
  602. client.on('command', async command => {
  603. logCommand(command)
  604. switch (command.sender) {
  605. case 'server':
  606. switch (command.code) {
  607. case 'set socket id':
  608. client.socketId = command.socketId
  609. socketInfoMap[command.socketId] = {
  610. nickname: client.nickname,
  611. sharedSources
  612. }
  613. backend.loadSharedSources(command.socketId, sharedSources)
  614. return
  615. case 'initialize party':
  616. for (const [ socketId, info ] of Object.entries(command.socketInfo)) {
  617. const nickname = info.nickname
  618. const sharedSources = {
  619. name: namePartySources(nickname),
  620. isPartySources: true
  621. }
  622. sharedSources.items = info.sharedSources.map(
  623. item => deserializePartySource(item, sharedSources))
  624. socketInfoMap[socketId] = {
  625. nickname,
  626. sharedSources
  627. }
  628. backend.loadSharedSources(socketId, sharedSources)
  629. }
  630. await restoreBackend(backend, command.backend)
  631. attachPlaybackBackendListeners()
  632. // backend.on('QP: playing', QP => {
  633. // QP.once('received time data', () => {
  634. // client.sendCommand({code: 'status', status: 'sync playback'})
  635. // })
  636. // })
  637. return
  638. }
  639. // Again, no break. Client commands can come from the server.
  640. case 'client': {
  641. let QP = (
  642. command.queuePlayer &&
  643. backend.queuePlayers.find(QP => QP.id === command.queuePlayer)
  644. )
  645. switch (command.code) {
  646. case 'announce join': {
  647. const sharedSources = {
  648. name: namePartySources(command.senderNickname),
  649. isPartySources: true,
  650. items: []
  651. }
  652. socketInfoMap[command.senderSocketId] = {
  653. nickname: command.senderNickname,
  654. sharedSources
  655. }
  656. backend.loadSharedSources(command.senderSocketId, sharedSources)
  657. return
  658. }
  659. case 'clear queue':
  660. if (QP) silenceEvents(QP, ['clear queue'], () => QP.clearQueue())
  661. return
  662. case 'clear queue past':
  663. if (QP) silenceEvents(QP, ['clear queue past'], () => QP.clearQueuePast(
  664. restoreNewItem(command.track, getPlaylistSources())
  665. ))
  666. return
  667. case 'clear queue up to':
  668. if (QP) silenceEvents(QP, ['clear queue up to'], () => QP.clearQueueUpTo(
  669. restoreNewItem(command.track, getPlaylistSources())
  670. ))
  671. return
  672. case 'distribute queue':
  673. if (QP) silenceEvents(QP, ['distribute queue'], () => QP.distributeQueue(
  674. restoreNewItem(command.topItem),
  675. {
  676. how: command.opts.how,
  677. rangeEnd: command.opts.rangeEnd
  678. }
  679. ))
  680. return
  681. case 'play':
  682. if (QP) {
  683. QP.once('received time data', data => {
  684. client.sendCommand({
  685. code: 'status',
  686. status: 'ready to resume',
  687. queuePlayer: QP.id
  688. })
  689. })
  690. silenceEvents(QP, ['playing'], () => {
  691. QP.play(restoreNewItem(command.track, getPlaylistSources()))
  692. })
  693. }
  694. return
  695. case 'queue':
  696. if (QP) silenceEvents(QP, ['queue'], () => QP.queue(
  697. restoreNewItem(command.topItem, getPlaylistSources()),
  698. isItemRef(command.afterItem) ? restoreNewItem(command.afterItem, getPlaylistSources()) : command.afterItem,
  699. {
  700. movePlayingTrack: command.opts.movePlayingTrack
  701. }
  702. ))
  703. return
  704. case 'restore queue':
  705. if (QP) {
  706. QP.replaceAllItems(command.tracks.map(
  707. refData => restoreNewItem(refData, getPlaylistSources())
  708. ))
  709. }
  710. return
  711. case 'seek to':
  712. if (QP) silenceEvents(QP, ['seek to'], () => QP.seekTo(command.time))
  713. return
  714. case 'set nickname': {
  715. const info = socketInfoMap[command.senderSocketId]
  716. info.nickname = command.senderNickname
  717. info.sharedSources.name = namePartySources(command.senderNickname)
  718. backend.sharedSourcesUpdated(client.socketId, info.sharedSources)
  719. return
  720. }
  721. case 'set pause': {
  722. // All this code looks very scary???
  723. /*
  724. // TODO: there's an event leak here when toggling pause while
  725. // nothing is playing
  726. let playingThisTrack = true
  727. QP.once('playing new track', () => {
  728. playingThisTrack = false
  729. })
  730. setTimeout(() => {
  731. if (playingThisTrack) {
  732. if (QP) silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused))
  733. }
  734. }, command.startingTrack ? 500 : 0)
  735. */
  736. silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused))
  737. return
  738. }
  739. case 'added queue player': {
  740. silenceEvents(backend, ['added queue player'], () => {
  741. const QP = backend.addQueuePlayer()
  742. QP.id = command.id
  743. })
  744. return
  745. }
  746. case 'share with party': {
  747. const { sharedSources } = socketInfoMap[command.senderSocketId]
  748. const deserialized = deserializePartySource(command.item, sharedSources)
  749. sharedSources.items.push(deserialized)
  750. backend.sharedSourcesUpdated(command.senderSocketId, sharedSources)
  751. return
  752. }
  753. case 'stop playing':
  754. if (QP) silenceEvents(QP, ['playing'], () => QP.stopPlaying())
  755. return
  756. case 'unqueue':
  757. if (QP) silenceEvents(QP, ['unqueue'], () => QP.unqueue(
  758. restoreNewItem(command.topItem, getPlaylistSources())
  759. ))
  760. return
  761. }
  762. }
  763. }
  764. })
  765. backend.on('announce join party', () => {
  766. client.sendCommand({
  767. code: 'announce join'
  768. })
  769. })
  770. backend.on('share with party', item => {
  771. if (sharedSources.items.every(x => x[originalSymbol] !== item)) {
  772. const serialized = serializePartySource(item)
  773. const deserialized = deserializePartySource(serialized)
  774. deserialized[parentSymbol] = sharedSources
  775. deserialized[originalSymbol] = item
  776. sharedSources.items.push(deserialized)
  777. backend.sharedSourcesUpdated(client.socketId, sharedSources)
  778. updateRestoredTracksUsingPlaylists(backend, getPlaylistSources())
  779. client.sendCommand({
  780. code: 'share with party',
  781. item: serialized
  782. })
  783. }
  784. })
  785. backend.on('set party nickname', nickname => {
  786. let oldNickname = client.nickname
  787. sharedSources.name = namePartySources(nickname)
  788. client.nickname = nickname
  789. client.sendCommand({code: 'set nickname', nickname, oldNickname})
  790. })
  791. function attachPlaybackBackendListeners() {
  792. backend.on('QP: clear queue', queuePlayer => {
  793. client.sendCommand({
  794. code: 'clear queue',
  795. queuePlayer: queuePlayer.id
  796. })
  797. })
  798. backend.on('QP: clear queue past', (queuePlayer, track) => {
  799. client.sendCommand({
  800. code: 'clear queue past',
  801. queuePlayer: queuePlayer.id,
  802. track: saveItemReference(track)
  803. })
  804. })
  805. backend.on('QP: clear queue up to', (queuePlayer, track) => {
  806. client.sendCommand({
  807. code: 'clear queue up to',
  808. queuePlayer: queuePlayer.id,
  809. track: saveItemReference(track)
  810. })
  811. })
  812. backend.on('QP: distribute queue', (queuePlayer, topItem, opts) => {
  813. client.sendCommand({
  814. code: 'distribute queue',
  815. queuePlayer: queuePlayer.id,
  816. topItem: saveItemReference(topItem),
  817. opts
  818. })
  819. })
  820. backend.on('QP: done playing', queuePlayer => {
  821. client.sendCommand({
  822. code: 'status',
  823. status: 'done playing',
  824. queuePlayer: queuePlayer.id
  825. })
  826. })
  827. backend.on('QP: playing', (queuePlayer, track) => {
  828. if (track) {
  829. client.sendCommand({
  830. code: 'play',
  831. queuePlayer: queuePlayer.id,
  832. track: saveItemReference(track)
  833. })
  834. queuePlayer.once('received time data', data => {
  835. client.sendCommand({
  836. code: 'status',
  837. status: 'ready to resume',
  838. queuePlayer: queuePlayer.id
  839. })
  840. })
  841. } else {
  842. client.sendCommand({
  843. code: 'stop playing',
  844. queuePlayer: queuePlayer.id
  845. })
  846. }
  847. })
  848. backend.on('QP: queue', (queuePlayer, topItem, afterItem, opts) => {
  849. client.sendCommand({
  850. code: 'queue',
  851. queuePlayer: queuePlayer.id,
  852. topItem: saveItemReference(topItem),
  853. afterItem: saveItemReference(afterItem),
  854. opts
  855. })
  856. })
  857. function handleSeek(queuePlayer) {
  858. client.sendCommand({
  859. code: 'seek to',
  860. queuePlayer: queuePlayer.id,
  861. time: queuePlayer.time
  862. })
  863. }
  864. backend.on('QP: seek ahead', handleSeek)
  865. backend.on('QP: seek back', handleSeek)
  866. backend.on('QP: seek to', handleSeek)
  867. backend.on('QP: shuffle queue', queuePlayer => {
  868. client.sendCommand({
  869. code: 'restore queue',
  870. why: 'shuffle',
  871. queuePlayer: queuePlayer.id,
  872. tracks: queuePlayer.queueGrouplike.items.map(saveItemReference)
  873. })
  874. })
  875. backend.on('QP: toggle pause', queuePlayer => {
  876. client.sendCommand({
  877. code: 'set pause',
  878. queuePlayer: queuePlayer.id,
  879. paused: queuePlayer.player.isPaused
  880. })
  881. })
  882. backend.on('QP: unqueue', (queuePlayer, topItem) => {
  883. client.sendCommand({
  884. code: 'unqueue',
  885. queuePlayer: queuePlayer.id,
  886. topItem: saveItemReference(topItem)
  887. })
  888. })
  889. backend.on('added queue player', (queuePlayer) => {
  890. client.sendCommand({
  891. code: 'added queue player',
  892. id: queuePlayer.id,
  893. })
  894. })
  895. }
  896. }
  897. export function attachSocketServerToBackend(server, backend) {
  898. // Unlike the function for attaching a backend to follow commands from a
  899. // client (attachBackendToSocketClient), this function is minimalistic.
  900. // It just sets the associated "canonical" backend. Actual logic for
  901. // de/serialization lives in serialized-backend.js.
  902. server.canonicalBackend = backend
  903. }