internal PlayQueue(DACPServer server, byte[] data) { var nodes = DACPUtility.GetResponseNodes(data); foreach (var itemNode in nodes) { switch (itemNode.Key) { case "ceQk": ID = itemNode.Value.GetStringValue(); break; case "ceQi": StartIndex = itemNode.Value.GetInt32Value(); break; case "ceQm": ItemCount = itemNode.Value.GetInt32Value(); break; case "ceQl": Title1 = itemNode.Value.GetStringValue(); break; case "ceQh": Title2 = itemNode.Value.GetStringValue(); break; } } }
internal async Task <DACPResponse> SubmitRequestAsync(DACPRequest request) { if (request.IncludeSessionID) { request.QueryParameters["session-id"] = SessionID.ToString(); } string uri = request.GetURI(); _log.Info("Submitting request for: " + uri); HttpResponseMessage response = await HttpClient.PostAsync(uri, request.HttpContent, request.CancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _log.Info("Invalid response ({0}) for: {1}", response.StatusCode, uri); throw new DACPRequestException(response); } byte[] data = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); _log.Info("Received response for: " + uri); // Get the content of the first node IEnumerable <DACPNode> nodes = null; if (data.Length > 0) { data = DACPUtility.GetResponseNodes(data, true).First().Value; nodes = DACPUtility.GetResponseNodes(data); } return(new DACPResponse(response, nodes)); }
public DateTime GetDateTime(string key, DateTime defaultValue = default(DateTime)) { if (!this.ContainsKey(key)) { return(defaultValue); } return(DACPUtility.GetDateTimeValue(this[key])); }
public bool GetBool(string key, bool defaultValue = default(bool)) { if (!this.ContainsKey(key)) { return(defaultValue); } return(DACPUtility.GetBoolValue(this[key])); }
public long GetLong(string key, long defaultValue = default(long)) { if (!this.ContainsKey(key)) { return(defaultValue); } return(DACPUtility.GetInt64Value(this[key])); }
public int?GetNullableInt(string key, int?defaultValue = default(int?)) { if (!this.ContainsKey(key)) { return(defaultValue); } return(DACPUtility.GetInt32Value(this[key])); }
public short GetShort(string key, short defaultValue = default(short)) { if (!this.ContainsKey(key)) { return(defaultValue); } return(DACPUtility.GetInt16Value(this[key])); }
public string GetString(string key, string defaultValue = default(string)) { if (!this.ContainsKey(key)) { return(defaultValue); } return(DACPUtility.GetStringValue(this[key])); }
protected async Task <bool> UpdateCurrentSongUserRatingAsync() { // Make sure we have all the values we need if (CurrentDatabaseID == 0 || CurrentContainerID == 0 || CurrentItemID == 0 || CurrentAlbumPersistentID == 0) { ClearCurrentSongUserRating(); return(true); } // Make sure this is for the main DB if (!ShowUserRating || MainDatabase == null || CurrentDatabaseID != MainDatabase.ID) { ClearCurrentSongUserRating(); return(true); } // If we're requesting the rating for a new song, clear out the old value if (CurrentItemID != _ratingUpdatedForSongID) { ClearCurrentSongUserRating(); } DACPRequest request = new DACPRequest("/databases/{0}/containers/{1}/items", CurrentDatabaseID, CurrentContainerID); request.QueryParameters["meta"] = "dmap.itemid,dmap.containeritemid,daap.songuserrating"; request.QueryParameters["type"] = "music"; request.QueryParameters["sort"] = "album"; var query = DACPQueryCollection.And(DACPQueryPredicate.Is("daap.songalbumid", CurrentAlbumPersistentID), DACPQueryPredicate.Is("dmap.itemid", CurrentItemID)); request.QueryParameters["query"] = query.ToString(); try { var response = await SubmitRequestAsync(request).ConfigureAwait(false); var mlcl = response.Nodes.First(n => n.Key == "mlcl"); var songNodes = DACPUtility.GetResponseNodes(mlcl.Value); foreach (var songData in songNodes) { var nodes = DACPNodeDictionary.Parse(songData.Value); var id = nodes.GetInt("miid"); if (id != CurrentItemID) { continue; } var rating = nodes.GetByte("asur"); SetCurrentSongUserRatingFromServer(rating); break; } } catch { ClearCurrentSongUserRating(); return(false); } return(true); }
private string GetDebugBytes(string code, byte[] body, int tabLevel, StringBuilder sb = null) { string debugText; string tab1 = new string('\t', tabLevel - 1); string tab2 = new string('\t', tabLevel); if (sb == null) { sb = new StringBuilder("Response content:\n"); } sb.AppendFormat(tab1 + "{0}[{1,3}] +++", code, body.Length).AppendLine(); var nodes = DACPUtility.GetResponseNodes(body); foreach (var kvp in nodes) { if (containerNodes.Contains(kvp.Key)) { GetDebugBytes(kvp.Key, kvp.Value, tabLevel + 1, sb); } else { debugText = string.Format(tab2 + "{0}[{1,3}] ", kvp.Key, kvp.Value.Length); switch (kvp.Value.Length) { case 1: debugText += string.Format(" 0x{0:x2} = {0}", kvp.Value[0]); break; case 2: Int16 value = kvp.Value.GetInt16Value(); debugText += string.Format(" 0x{0:x4} = {0}", value); if (value >= 32 && value <= 126) { debugText += string.Format(" ({0})", (char)value); } break; case 4: debugText += string.Format(" 0x{0:x8} = {0}", kvp.Value.GetInt32Value()); break; case 8: debugText += string.Format(" 0x{0:x16} = {0}", kvp.Value.GetInt64Value()); break; default: debugText += " => " + kvp.Value.GetStringValue(); break; } sb.AppendLine(debugText); } } return(sb.ToString()); }
internal async Task <List <T> > GetListAsync <T>(DACPRequest request, Func <DACPNodeDictionary, T> itemGenerator, string listKey = DACPUtility.DefaultListKey) { try { var response = await SubmitRequestAsync(request).ConfigureAwait(false); return(DACPUtility.GetItemsFromNodes(response.Nodes, itemGenerator, listKey).ToList()); } catch (Exception) { return(new List <T>()); } }
internal async Task <IDACPList> GetAlphaGroupedListAsync <T>(DACPRequest request, Func <byte[], T> itemGenerator, string listKey = DACPUtility.DefaultListKey) { try { var response = await SubmitRequestAsync(request).ConfigureAwait(false); return(DACPUtility.GetAlphaGroupedDACPList(response.Nodes, itemGenerator, listKey)); } catch (Exception) { return(new DACPList <T>(false)); } }
protected async Task <bool> GetServerInfoAsync() { DACPRequest request = new DACPRequest("/server-info"); request.IncludeSessionID = false; try { var response = await SubmitRequestAsync(request).ConfigureAwait(false); // Process response ServerVersionString = response.HTTPResponse.Headers.GetValues("DAAP-Server").FirstOrDefault(); var nodes = DACPNodeDictionary.Parse(response.Nodes); // Fixing an issue with Apple TV devices where \0 may be appended to the end of the library name string libraryName = nodes.GetString("minm"); if (!string.IsNullOrEmpty(libraryName)) { libraryName = libraryName.Replace("\0", ""); } LibraryName = libraryName; ServerVersion = nodes.GetInt("aeSV"); ServerDMAPVersion = nodes.GetInt("mpro"); ServerDAAPVersion = nodes.GetInt("apro"); // MAC addresses if (nodes.ContainsKey("msml")) { List <string> macAddresses = new List <string>(); var addressNodes = DACPUtility.GetResponseNodes(nodes["msml"]).Where(n => n.Key == "msma").Select(n => n.Value); foreach (var addressNode in addressNodes) { var address = BitConverter.ToInt64(addressNode, 0); address = address >> 16; macAddresses.Add(address.ToString("X12")); } MACAddresses = macAddresses.ToArray(); } } catch (Exception e) { HandleHTTPException(request, e); return(false); } return(true); }
public static IDACPList GetAlphaGroupedDACPList <T>(IEnumerable <DACPNode> nodes, Func <byte[], T> itemGenerator, out List <T> items, string listKey = DefaultListKey, bool useGroupMinimums = true) { var nodeList = nodes.ToList(); items = GetItemsFromNodes(nodeList, itemGenerator, listKey).ToList(); var headers = nodeList.FirstOrDefault(n => n.Key == "mshl"); IEnumerable <DACPNode> headerNodes = null; if (headers != null) { headerNodes = DACPUtility.GetResponseNodes(headers.Value); } return(GetAlphaGroupedDACPList(items, headerNodes, useGroupMinimums)); }
protected async Task <bool> GetServerCapabilitiesAsync() { DACPRequest request = new DACPRequest("/ctrl-int"); request.IncludeSessionID = false; try { var response = await SubmitRequestAsync(request).ConfigureAwait(false); // Process response var mlcl = DACPUtility.GetResponseNodes(response.Nodes.First(n => n.Key == "mlcl").Value); var nodes = DACPNodeDictionary.Parse(mlcl.First(n => n.Key == "mlit").Value); if (nodes.ContainsKey("ceSX")) { Int64 ceSX = nodes.GetLong("ceSX"); // Bit 0: Supports Play Queue if ((ceSX & (1 << 0)) != 0) { SupportsPlayQueue = true; } // Bit 1: iTunes Radio? Appeared in iTunes 11.1.2 with the iTunes Radio DB. // Apple's Remote for iOS doesn't seem to use this bit to determine whether iTunes Radio is available. // Instead, it looks for an iTunes Radio database and checks whether it has any containers. // Bit 2: Genius Shuffle Enabled/Available if ((ceSX & (1 << 2)) != 0) { SupportsGeniusShuffle = true; } } // Apple TV // TODO: Is this the best way to detect this? IsAppleTV = nodes.GetBool("ceDR"); } catch (Exception e) { HandleHTTPException(request, e); return(false); } return(true); }
/// <summary> /// Requests an update for the keyboard state and session ID. /// </summary> private async Task <bool> RequestAppleTVKeyboardInfoUpdateAsync() { List <byte> contentBytes = new List <byte>(); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmcc", "0")); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmbe", "PromptResendReq")); ByteArrayContent content = new ByteArrayContent(contentBytes.ToArray()); DACPRequest request = new DACPRequest("/ctrl-int/1/controlpromptentry"); request.HttpContent = content; try { await SubmitRequestAsync(request).ConfigureAwait(false); } catch { return(false); } return(true); }
/// <summary> /// Requests an update for the virtual trackpad connection parameters. /// </summary> private async Task <bool> RequestAppleTVTrackpadInfoUpdateAsync() { List <byte> contentBytes = new List <byte>(); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmcc", "0")); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmbe", "DRPortInfoRequest")); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmte", string.Format("{0},0x{1}", AppleTVEncryptionKey, PairingCode))); ByteArrayContent content = new ByteArrayContent(contentBytes.ToArray()); DACPRequest request = new DACPRequest("/ctrl-int/1/controlpromptentry"); request.HttpContent = content; try { await SubmitRequestAsync(request).ConfigureAwait(false); } catch { return(false); } return(true); }
internal PlayQueueItem(DACPServer server, byte[] data) { Server = server; var nodes = DACPUtility.GetResponseNodes(data); foreach (var node in nodes) { switch (node.Key) { case "ceQs": byte[] value = node.Value; byte[] dbID = { value[0], value[1], value[2], value[3] }; byte[] songID = { value[12], value[13], value[14], value[15] }; DatabaseID = dbID.GetInt32Value(); SongID = songID.GetInt32Value(); break; case "ceQn": SongName = node.Value.GetStringValue(); break; case "ceQr": ArtistName = node.Value.GetStringValue(); break; case "ceQa": AlbumName = node.Value.GetStringValue(); break; case "ceQI": // ceQI is a queue index value. The "currently playing" song has an index of 1. The first queued item has // an index of 2. The first "history" item has an index of 0. // This appears to be one off from how the index values are dealt with elsewhere, so I'm subtracting 1 // from the ceQI value to get the item's queue index. QueueIndex = node.Value.GetInt32Value() - 1; break; } } }
private async Task <bool> SendAppleTVKeyboardStringUpdateCommandAsync(string value, bool done) { string command = (done) ? "PromptDone" : "PromptUpdate"; List <byte> contentBytes = new List <byte>(); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmcc", _appleTVKeyboardSessionID)); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmbe", command)); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmte", value)); ByteArrayContent content = new ByteArrayContent(contentBytes.ToArray()); DACPRequest request = new DACPRequest("/ctrl-int/1/controlpromptentry"); request.HttpContent = content; try { await SubmitRequestAsync(request).ConfigureAwait(false); } catch { return(false); } return(true); }
public static string QueryEncodeString(string input) { return(Uri.EscapeDataString(DACPUtility.EscapeSingleQuotes(input))); }
public static DACPNodeDictionary Parse(byte[] data) { return(Parse(DACPUtility.GetResponseNodes(data))); }
protected void HTTPByteCallback(IAsyncResult result) { // Get the HTTPRequestInfo object HTTPRequestInfo requestInfo = (HTTPRequestInfo)result.AsyncState; _log.Info("Got HTTP response for: " + requestInfo.WebRequest.RequestUri); try { WebResponse response = requestInfo.WebRequest.EndGetResponse(result); requestInfo.WebResponse = response as HttpWebResponse; if (!IsConnected) { return; } Stream responseStream = response.GetResponseStream(); BinaryReader br = new BinaryReader(responseStream); MemoryStream data = new MemoryStream(); byte[] buffer; do { buffer = br.ReadBytes(8192); data.Write(buffer, 0, buffer.Length); } while (buffer.Length > 0); data.Flush(); byte[] byteResult = data.GetBuffer(); var parsedResponse = DACPUtility.GetResponseNodes(byteResult).FirstOrDefault(); if (parsedResponse != null) { requestInfo.ResponseCode = parsedResponse.Key; requestInfo.ResponseBody = parsedResponse.Value; } if (requestInfo.ResponseHandlerDelegate != null) { requestInfo.ResponseHandlerDelegate(requestInfo); } } catch (Exception e) { _log.Warning("Caught exception for {0}: {1}", requestInfo.WebRequest.RequestUri, e.Message); _log.Debug("Exception details: " + e.ToString()); if (e is WebException) { WebException webException = (WebException)e; _log.Warning("Caught web exception: " + webException.Message); _log.Debug("WebException Status: " + webException.Status.ToString()); if (webException.Status == WebExceptionStatus.RequestCanceled) { lock (PendingHttpRequests) { if (!PendingHttpRequests.Contains(requestInfo)) { return; } } } if (requestInfo.ExceptionHandlerDelegate != null) { requestInfo.ExceptionHandlerDelegate(requestInfo, webException); return; } } StringBuilder errorString = new StringBuilder("HTTPByteCallback Error:\r\n"); errorString.AppendLine("URL: " + requestInfo.WebRequest.RequestUri.GetPathAndQueryString()); errorString.AppendLine(e.ToString()); _log.Error("Unhandled web exception."); _log.Debug(errorString.ToString()); HandleConnectionError(errorString.ToString()); } finally { lock (PendingHttpRequests) PendingHttpRequests.Remove(requestInfo); UpdateGettingData(); } }
public async Task <bool> SendAppleTVKeyboardSecureTextAsync(string value) { if (!_appleTVKeyboardSecureText || string.IsNullOrEmpty(_appleTVKeyboardSecureTextCertificate) || string.IsNullOrEmpty(_appleTVKeyboardSecureTextChallenge)) { return(false); } byte[] encryptedBytes; try { // Read the certificate byte[] certificateBytes = BitUtility.FromHexString(_appleTVKeyboardSecureTextCertificate); var certificate = new X509Certificate(certificateBytes); // X509Certificate gives us the ASN.1 DER-encoded RSA public key var publicKeyEncoded = certificate.GetPublicKey(); if (publicKeyEncoded[0] != 0x30 || publicKeyEncoded[1] != 0x81) { throw new Exception(); } // Byte at index 2 is the length of the entire sequence, we can ignore this if (publicKeyEncoded[3] != 0x02 || publicKeyEncoded[4] != 0x81) { throw new Exception(); } // Length of the public key int length = publicKeyEncoded[5]; int position = 6; // Skip any padding at the beginning while (publicKeyEncoded[position] == 0x00) { length--; position++; } // Get the public key byte[] publicKey = new byte[length]; System.Buffer.BlockCopy(publicKeyEncoded, position, publicKey, 0, length); // Length/position of exponent position += length; if (publicKeyEncoded[position] != 0x02) { throw new Exception(); } position++; length = publicKeyEncoded[position]; position++; // Get the exponent byte[] exponent = new byte[length]; System.Buffer.BlockCopy(publicKeyEncoded, position, exponent, 0, length); // Get bytes to encrypt var bytesToEncode = new List <byte>(); bytesToEncode.AddRange(BitUtility.FromHexString(_appleTVKeyboardSecureTextChallenge)); bytesToEncode.AddRange(Encoding.UTF8.GetBytes(value)); // Set up RSA parameters var rsaParameters = new RSAParameters(); rsaParameters.Modulus = publicKey; rsaParameters.Exponent = exponent; // Create RSA provider using (var rsa = new RSACryptoServiceProvider()) { rsa.ImportParameters(rsaParameters); // Encrypt the source data encryptedBytes = rsa.Encrypt(bytesToEncode.ToArray(), false); } } catch { return(false); } // Create DACP request List <byte> contentBytes = new List <byte>(); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmcc", _appleTVKeyboardSessionID)); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmbe", "PromptDone")); contentBytes.AddRange(DACPUtility.GetDACPFormattedBytes("cmae", BitUtility.ToHexString(encryptedBytes))); ByteArrayContent content = new ByteArrayContent(contentBytes.ToArray()); DACPRequest request = new DACPRequest("/ctrl-int/1/controlpromptentry"); request.HttpContent = content; try { await SubmitRequestAsync(request).ConfigureAwait(false); } catch { return(false); } return(true); }
private void HandlePlayQueueResponse(IEnumerable <DACPNode> responseNodes) { if (PlayQueues == null) { PlayQueues = new ObservableCollection <PlayQueue>(); } List <PlayQueue> queues = new List <PlayQueue>(); List <PlayQueueItem> queueItems = new List <PlayQueueItem>(); var mlcl = responseNodes.FirstOrDefault(n => n.Key == "mlcl"); if (mlcl != null) { var nodes = DACPUtility.GetResponseNodes(mlcl.Value); // Get the queues var ceQS = nodes.FirstOrDefault(n => n.Key == "ceQS"); if (ceQS != null) { queues.AddRange(DACPUtility.GetResponseNodes(ceQS.Value).Where(n => n.Key == "mlit").Select(n => new PlayQueue(this, n.Value))); } // Get the queue items queueItems.AddRange(nodes.Where(n => n.Key == "mlit").Select(n => new PlayQueueItem(this, n.Value))); } // Update the queues and queue items with minimal changes to avoid reloading the list while it's displayed. // This is optimized for simple inserts and deletions. Reordering items will still cause most of the list to reload. // Updating on the UI thread because of the observable collections being tied to UI elements. ThreadUtility.RunOnUIThread(() => { // Remove queues var removedQueues = PlayQueues.Where(q1 => !queues.Any(q2 => q1.ID == q2.ID)).ToArray(); foreach (var q in removedQueues) { PlayQueues.Remove(q); } // Update/insert queues for (int i = 0; i < queues.Count; i++) { var queue = queues[i]; // Add the queue to the list if we don't already have it if (PlayQueues.Count <= i || PlayQueues[i].ID != queue.ID) { PlayQueues.Insert(i, queue); } // Update the existing queue object's start index and item count else { PlayQueues[i].Title1 = queue.Title1; PlayQueues[i].Title2 = queue.Title2; PlayQueues[i].StartIndex = queue.StartIndex; PlayQueues[i].ItemCount = queue.ItemCount; } } // Remove extra queues while (PlayQueues.Count > queues.Count) { PlayQueues.RemoveAt(queues.Count); } // Put queue items in queues foreach (var queue in PlayQueues) { int start = queue.StartIndex; int stop = start + queue.ItemCount; var items = queueItems.Where(i => i.QueueIndex >= start && i.QueueIndex < stop).OrderBy(i => i.QueueIndex).ToArray(); // Remove items var removedItems = queue.Where(i1 => !items.Any(i2 => i1.SongID == i2.SongID && i1.DatabaseID == i2.DatabaseID)).ToArray(); foreach (var i in removedItems) { queue.Remove(i); } // Update/insert items for (int i = 0; i < items.Length; i++) { if (queue.Count <= i || queue[i].SongID != items[i].SongID || queue[i].DatabaseID != items[i].DatabaseID) { queue.Insert(i, items[i]); } else { queue[i].QueueIndex = items[i].QueueIndex; } } // Remove extra items while (queue.Count > items.Length) { queue.RemoveAt(items.Length); } } // Update upcoming songs string upcomingSongName1 = null; string upcomingSongName2 = null; // If an upcoming song is from a different artist than the currently playing artist, display both the // artist name and the song name. Also, if the first upcoming song is from a different artist, make sure // the artist name is shown for both songs to avoid any confusion. bool includeArtistName = false; var upcomingItem1 = queueItems.FirstOrDefault(i => i.QueueIndex == 1); if (upcomingItem1 != null) { if (upcomingItem1.ArtistName != CurrentArtist) { includeArtistName = true; } if (includeArtistName) { upcomingSongName1 = upcomingItem1.ArtistName + " – " + upcomingItem1.SongName; } else { upcomingSongName1 = upcomingItem1.SongName; } } var upcomingItem2 = queueItems.FirstOrDefault(i => i.QueueIndex == 2); if (upcomingItem2 != null) { if (upcomingItem2.ArtistName != CurrentArtist) { includeArtistName = true; } if (includeArtistName) { upcomingSongName2 = upcomingItem2.ArtistName + " – " + upcomingItem2.SongName; } else { upcomingSongName2 = upcomingItem2.SongName; } } PlayQueueUpcomingSongName1 = upcomingSongName1; PlayQueueUpcomingSongName2 = upcomingSongName2; }); }
protected void ProcessGetSpeakersResponse(HTTPRequestInfo requestInfo) { List <UInt64> foundSpeakerIDs = new List <UInt64>(); string name; UInt64 id; bool hasPassword; bool hasVideo; bool active; int volume; foreach (var kvp in requestInfo.ResponseNodes) { if (kvp.Key == "mdcl") { name = string.Empty; id = 0; hasPassword = false; hasVideo = false; active = false; volume = 0; var speakerNodes = DACPUtility.GetResponseNodes(kvp.Value); foreach (var speakerKvp in speakerNodes) { switch (speakerKvp.Key) { case "minm": // Speaker name name = speakerKvp.Value.GetStringValue(); break; case "msma": // Speaker ID id = (UInt64)speakerKvp.Value.GetInt64Value(); break; case "cahp": // Has Password hasPassword = (speakerKvp.Value[0] > 0); break; case "caiv": // Has Video hasVideo = true; break; case "caia": // Speaker active active = (speakerKvp.Value[0] > 0); break; case "cmvo": // Speaker volume volume = speakerKvp.Value.GetInt32Value(); break; default: break; } } if (foundSpeakerIDs.Contains(id)) { continue; } foundSpeakerIDs.Add(id); AirPlaySpeaker speaker; lock (Speakers) { speaker = Speakers.FirstOrDefault(s => s.ID == id); if (speaker == null) { speaker = new AirPlaySpeaker(this, id); speaker.HasVideo = (hasVideo || id == 0); ThreadUtility.RunOnUIThread(() => Speakers.Add(speaker)); } } speaker.HasPassword = hasPassword; speaker.Name = name; speaker.Active = active; speaker.Volume = volume; speaker.WaitingForActiveResponse = false; } } lock (Speakers) { // Handle speakers that are no longer available // Need to call ToList() so the source collection doesn't change during enumeration var removedSpeakers = Speakers.Where(s => !foundSpeakerIDs.Contains(s.ID)).ToList(); foreach (AirPlaySpeaker removedSpeaker in removedSpeakers) { ThreadUtility.RunOnUIThread(() => Speakers.Remove(removedSpeaker)); } } SendAirPlaySpeakerUpdate(); gettingSpeakers = false; }