From ad8f3985a3c288c2a9bda31b5576a17b9dfd0064 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 6 Dec 2023 09:04:14 +0100 Subject: [PATCH] Added websocket wrapper for TCP connection to Android receiver. --- clients/chrome/background.js | 375 ++++++++++++++++++ clients/chrome/icons/icon128.png | Bin 0 -> 926 bytes clients/chrome/icons/icon16.png | Bin 0 -> 489 bytes clients/chrome/icons/icon48.png | Bin 0 -> 595 bytes clients/chrome/manifest.json | 23 ++ clients/chrome/popup.html | 66 +++ clients/chrome/popup.js | 228 +++++++++++ clients/terminal/src/main.rs | 2 +- clients/terminal/src/models.rs | 7 +- receivers/android/app/build.gradle | 1 + .../android/app/src/main/AndroidManifest.xml | 2 +- .../com/futo/fcast/receiver/BootReceiver.kt | 4 +- .../futo/fcast/receiver/DiscoveryService.kt | 23 +- .../com/futo/fcast/receiver/FCastSession.kt | 43 +- .../com/futo/fcast/receiver/MainActivity.kt | 24 +- .../java/com/futo/fcast/receiver/Models.kt | 3 +- .../com/futo/fcast/receiver/NetworkService.kt | 285 +++++++++++++ .../java/com/futo/fcast/receiver/Packets.kt | 5 +- .../com/futo/fcast/receiver/PlayerActivity.kt | 12 +- .../futo/fcast/receiver/TcpListenerService.kt | 249 +----------- .../receiver/WebSocketListenerService.kt | 69 ++++ .../fcast/receiver/WebSocketOutputStream.kt | 21 + 22 files changed, 1165 insertions(+), 277 deletions(-) create mode 100644 clients/chrome/background.js create mode 100644 clients/chrome/icons/icon128.png create mode 100644 clients/chrome/icons/icon16.png create mode 100644 clients/chrome/icons/icon48.png create mode 100644 clients/chrome/manifest.json create mode 100644 clients/chrome/popup.html create mode 100644 clients/chrome/popup.js create mode 100644 receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt create mode 100644 receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketListenerService.kt create mode 100644 receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketOutputStream.kt diff --git a/clients/chrome/background.js b/clients/chrome/background.js new file mode 100644 index 0000000..8d0c62d --- /dev/null +++ b/clients/chrome/background.js @@ -0,0 +1,375 @@ +let mediaUrls = []; +let hosts = []; +let currentWebSocket = null; +let playbackState = null; +let volume = 1.0; +let selectedHost = null; + +const Opcode = { + None: 0, + Play: 1, + Pause: 2, + Resume: 3, + Stop: 4, + Seek: 5, + PlaybackUpdate: 6, + VolumeUpdate: 7, + SetVolume: 8, +}; + +chrome.runtime.onInstalled.addListener(function() { + console.log("onInstalled"); + chrome.storage.local.get(['hosts', 'selectedHost'], function(result) { + console.log("load persistence", result); + + hosts = result.hosts || []; + selectedHost = result.selectedHost || null; + + if (selectedHost) { + maintainWebSocketConnection(selectedHost) + } + notifyPopup('updateHosts'); + notifyPopup('updateUrls'); + }); +}); + +chrome.webRequest.onHeadersReceived.addListener( + function(details) { + console.log(`onHeadersReceived (${details.url})`, details); + const contentType = details.responseHeaders.find(header => header.name.toLowerCase() === 'content-type')?.value; + if (!contentType) { + return; + } + + const isMedia = contentType.startsWith('video/') || + contentType.startsWith('audio/') || + contentType.toLowerCase() == "application/x-mpegurl" || + contentType.toLowerCase() == "application/dash+xml"; + const isSegment = details.url.endsWith(".ts"); + + if (contentType && isMedia && !isSegment) { + if (!mediaUrls.some(v => v.url === details.url)) + mediaUrls.push({contentType, url: details.url}); + console.log('Media URL found:', {contentType, url: details.url}); + notifyPopup('updateUrls'); + } + }, + { urls: [""] }, + ["responseHeaders"] +); + +chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { + if (request.action === 'getUrls') { + sendResponse({ urls: mediaUrls, selectedHost }); + } else if (request.action === 'clearAll') { + mediaUrls = []; + notifyPopup('updateUrls'); + } else if (request.action === 'deleteUrl') { + mediaUrls = mediaUrls.filter(url => url !== request.url); + notifyPopup('updateUrls'); + } else if (request.action === 'addHost') { + hosts.push(request.host); + chrome.storage.local.set({ 'hosts': hosts }, function () { + console.log('Hosts saved', hosts); + }); + notifyPopup('updateHosts'); + } else if (request.action === 'selectHost') { + selectedHost = request.host; + chrome.storage.local.set({ 'selectedHost': selectedHost }, function () { + console.log('Selected host saved', selectedHost); + }); + + maintainWebSocketConnection(selectedHost); + notifyPopup('updateHosts'); + notifyPopup('updateUrls'); + } else if (request.action === 'deleteHost') { + hosts = hosts.filter(host => host !== request.host); + if (selectedHost === request.host) { + selectedHost = null; + chrome.storage.local.set({ 'selectedHost': selectedHost }, function () { + console.log('Selected host cleared'); + }); + } + + chrome.storage.local.set({ 'hosts': hosts }, function () { + console.log('Hosts updated after deletion'); + }); + notifyPopup('updateHosts'); + notifyPopup('updateUrls'); + } else if (request.action === 'castVideo') { + play(selectedHost, { + container: request.url.contentType, + url: request.url.url + }); + } else if (request.action === 'getHosts') { + sendResponse({ hosts, selectedHost }); + } else if (request.action == 'getPlaybackState') { + sendResponse({ selectedHost, playbackState }); + } else if (request.action == 'getVolume') { + sendResponse({ volume }); + } else if (request.action === 'resume') { + resume(selectedHost); + } else if (request.action === 'pause') { + pause(selectedHost); + } else if (request.action === 'stop') { + stop(selectedHost); + } else if (request.action === 'setVolume') { + setVolume(selectedHost, request.volume); + } else if (request.action === 'seek') { + seek(selectedHost, request.time); + } +}); + +function closeCurrentWebSocket() { + if (currentWebSocket) { + console.log('Closing current WebSocket connection'); + currentWebSocket.close(); + currentWebSocket = null; + } +} + +function notifyPopup(action) { + chrome.runtime.sendMessage({ action: action }); +} + +function maintainWebSocketConnection(host) { + closeCurrentWebSocket(); + + if (!host) { + console.log('No host selected, stopping WebSocket connection'); + return; + } + + let hostAddress, port; + const portIndex = host.indexOf(':'); + if (portIndex === -1) { + hostAddress = host; + port = 46899; + } else { + hostAddress = host.substring(0, portIndex); + port = host.substring(portIndex + 1, host.length); + } + + const wsUrl = `ws://${hostAddress}:${port}`; + currentWebSocket = new WebSocket(wsUrl); + + currentWebSocket.onopen = function() { + console.log('WebSocket connection opened to ' + wsUrl); + }; + + currentWebSocket.onerror = function(error) { + console.error('WebSocket error:', error); + }; + + currentWebSocket.onclose = function(event) { + console.log('WebSocket connection closed:', event.reason); + if (selectedHost === host) { + console.log('Attempting to reconnect...'); + setTimeout(() => maintainWebSocketConnection(host), 1000); + } + }; + + const LENGTH_BYTES = 4; + const MAXIMUM_PACKET_LENGTH = 32 * 1024; + const SessionState = { + WaitingForLength: 0, + WaitingForData: 1 + }; + + let state = SessionState.WaitingForLength; + let packetLength = 0; + let bytesRead = 0; + let buffer = new Uint8Array(MAXIMUM_PACKET_LENGTH); + + function handleLengthBytes(dataView, offset, count) { + let bytesToRead = Math.min(LENGTH_BYTES - bytesRead, count); + let bytesRemaining = count - bytesToRead; + for (let i = 0; i < bytesToRead; i++) { + buffer[bytesRead + i] = dataView.getUint8(offset + i); + } + bytesRead += bytesToRead; + + if (bytesRead >= LENGTH_BYTES) { + packetLength = dataView.getUint32(0, true); // true for little-endian + bytesRead = 0; + state = SessionState.WaitingForData; + + if (packetLength > MAXIMUM_PACKET_LENGTH) { + throw new Error("Maximum packet length exceeded"); + } + + if (bytesRemaining > 0) { + handlePacketBytes(dataView, offset + bytesToRead, bytesRemaining); + } + } + } + + function handlePacketBytes(dataView, offset, count) { + let bytesToRead = Math.min(packetLength - bytesRead, count); + let bytesRemaining = count - bytesToRead; + for (let i = 0; i < bytesToRead; i++) { + buffer[bytesRead + i] = dataView.getUint8(offset + i); + } + bytesRead += bytesToRead; + + if (bytesRead >= packetLength) { + handlePacket(); + + state = SessionState.WaitingForLength; + packetLength = 0; + bytesRead = 0; + + if (bytesRemaining > 0) { + handleLengthBytes(dataView, offset + bytesToRead, bytesRemaining); + } + } + } + + + function handlePacket() { + console.log(`Processing packet of ${bytesRead} bytes`); + + // Parse opcode and body + const opcode = buffer[0]; + const body = bytesRead > 1 ? new TextDecoder().decode(buffer.slice(1, bytesRead)) : null; + + console.log("Received body:", body); + + switch (opcode) { + case Opcode.PlaybackUpdate: + if (body) { + try { + const playbackUpdateMsg = JSON.parse(body); + console.log("Received playback update", playbackUpdateMsg); + playbackState = playbackUpdateMsg; + notifyPopup('updatePlaybackState'); + } catch (error) { + console.error("Error parsing playback update message:", error); + } + } + break; + + case Opcode.VolumeUpdate: + if (body) { + try { + const volumeUpdateMsg = JSON.parse(body); + console.log("Received volume update", volumeUpdateMsg); + volume = volumeUpdateMsg; + notifyPopup('updateVolume'); + } catch (error) { + console.error("Error parsing volume update message:", error); + } + } + break; + + default: + console.log(`Error handling packet`); + break; + } + } + + currentWebSocket.onmessage = function(event) { + if (typeof event.data === "string") { + console.log("Text message received, not handled:", event.data); + } else { + event.data.arrayBuffer().then((buffer) => { + let dataView = new DataView(buffer); + if (state === SessionState.WaitingForLength) { + handleLengthBytes(dataView, 0, buffer.byteLength); + } else if (state === SessionState.WaitingForData) { + handlePacketBytes(dataView, 0, buffer.byteLength); + } else { + console.error("Invalid state encountered"); + maintainWebSocketConnection(host); + } + }); + } + }; +} + +function sendWebSocketPacket(h, packet) { + let host; + let port; + const portIndex = h.indexOf(':'); + if (portIndex == -1) { + host = h; + port = 46899; + } else { + host = h.substring(0, portIndex); + port = h.substring(portIndex + 1, h.length); + } + + const wsUrl = `ws://${host}:${port}`; + const socket = new WebSocket(wsUrl); + socket.onopen = function() { + console.log('Connection opened to ' + wsUrl); + + socket.send(packet); + socket.close(); + console.log('Connection closed after sending packet'); + }; + + socket.onerror = function(error) { + console.error('WebSocket error:', error); + }; + + socket.onclose = function (event) { + console.log('WebSocket connection closed:', event.reason); + }; +} + +function createHeader(opcode, bodyLength) { + const buffer = new ArrayBuffer(5); + const view = new DataView(buffer); + view.setUint32(0, bodyLength + 1, true); // size (little endian) + view.setUint8(4, opcode); + return buffer; +} + +function createBody(jsonObject) { + const jsonString = JSON.stringify(jsonObject); + return new TextEncoder().encode(jsonString); +} + +function play(host, playMessage) { + const body = createBody(playMessage); + const header = createHeader(1, body.length); + const packet = concatenateBuffers(header, body); + sendWebSocketPacket(host, packet); +} + +function pause(host) { + const header = createHeader(2, 0); + sendWebSocketPacket(host, new Uint8Array(header)); +} + +function resume(host) { + const header = createHeader(3, 0); + sendWebSocketPacket(host, new Uint8Array(header)); +} + +function stop(host) { + const header = createHeader(4, 0); + sendWebSocketPacket(host, new Uint8Array(header)); +} + +function seek(host, time) { + const body = createBody({time}); + const header = createHeader(5, body.length); + const packet = concatenateBuffers(header, body); + sendWebSocketPacket(host, packet); +} + +function setVolume(host, volume) { + const body = createBody({volume}); + const header = createHeader(8, body.length); + const packet = concatenateBuffers(header, body); + sendWebSocketPacket(host, packet); +} + +function concatenateBuffers(buffer1, buffer2) { + const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); + tmp.set(new Uint8Array(buffer1), 0); + tmp.set(new Uint8Array(buffer2), buffer1.byteLength); + return tmp.buffer; +} \ No newline at end of file diff --git a/clients/chrome/icons/icon128.png b/clients/chrome/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..f54ebadb8e1593aea7e2e7d56125959f2716a93a GIT binary patch literal 926 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV9d*Ob`Ho)PG(@xm{>Z|+MC%? zq;G;aE{BazZgsv~VKzVNLexA?p#{Ah zcQfy9RD1kUg7^4wUAxfyw$Sy*e*cq|jXZqu;B_sVWzpNTZ4_mqOLdP%AF{1doGWT4 zz<0aI;i31^^i_YBSj0ayKU!vF$Xa(b`1JL{Wqn_pVnyVH_(acc`d2MiI(b3htl3j< zsc)8VXSwWh@kBtHr112_m8vJ-)W<(LZhp9{qU2}vn#68pf5$zEfu}EBoiI!I?u7Fx zzEA&|`>UH<1-+H)ar(UY`CdMU(`GfHHt#=Q_`I)A;RD0-rL*c?i=^ts_Xb^d`h8(5 z<8pCR-ObGDvoBQVFMa!^Uu43|^G_cz4bPpX{=^DEj8z!3A&L zNBv^E(w+XTv3U7gU<7a$ctjR6Fz6|RFk{71`!Zm3T=R5s45^5FdxepgK|$cK!-4(J z+Y=U>v**rwTNypgv|!Vd1N#{nk2ni7Fg#*=^h3^pp^&+-hF_s!N2A3)b^(Vw4l?(d zITZ2~`rb3L2$%~T|IWbFA>Hw~nt|~M?~#wTquS^nBp(^{%73%`@!!sp1dMhDPgg&e IbxsLQ0OMkG_5c6? literal 0 HcmV?d00001 diff --git a/clients/chrome/icons/icon16.png b/clients/chrome/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..e6637d2504becd0482ea30f93b30949f39117622 GIT binary patch literal 489 zcmV8LiJTF_da z2@;@<2;FJju_oj37!AkAEk0FmhE=b|`~DprS;Nu6*D5@wTed4aAskzEEyY{HJ$fOX z6+RFS+j2nROQmP3{G>Qvb(5e`nwyo{ zC$k@i1k*&($WUe(6PIDBCCTi6U*yL(!-{xx`CF`Eiz81#ykSwx($$cf6Wx%{WKHG% zH%})wI#TT(j6(CH=e-U<%Qk#fJn!?+^S+KC@Brtfntw%d8h;nPQqw~F(6s@l7d0)j z181AidvB%f^p6yf>CffhDv8U~~3 f0FsLaJ}&?OYsv&1nA_;|00000NkvXXu0mjftwqs& literal 0 HcmV?d00001 diff --git a/clients/chrome/icons/icon48.png b/clients/chrome/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..858f91d5f87ae40fa5f2d8830b3e4bf379146c2d GIT binary patch literal 595 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(Fy>`CI|pPYCo?c;Oe~#f?ak~c z(z;)zYkfznn37nu%f=1?kt0FLiNY#JINWk4omCSwE4+DEAxZm21dEP~bbRZ7hvn?0Fq57|~J&K0#2 z;JaPq@X&i{`l>%mEaIPf@grH$U7}QSvi-O=7pQzvG_7z|)tmPM9Tpcf$D; z->3h~{ngE_g5JvYIDKCHd@rBFX|tM8oA;kDeBRfm@PXm^(pmMcMN;+RdxI`J{l2i3 zak;pu?q=rn*%zwwm%jbdFEZif`KJ$_vRDOQsO5dzeRWOUb<2ItPj=Z{6n*pV;DR^r zqkgen=}!OFSiJl#FakIWJR*x381$4un6YB1eHl=&+tbA{B*XdbrGvZ&6a4aBj-KxW==^_cpW5xBc<%{?+OO_gS?A7)>sSJ2m!%H;6uRU=2`sESk`25x^*N vqJlL-kuQXS)hLcJtb" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + } +} \ No newline at end of file diff --git a/clients/chrome/popup.html b/clients/chrome/popup.html new file mode 100644 index 0000000..b209ab3 --- /dev/null +++ b/clients/chrome/popup.html @@ -0,0 +1,66 @@ + + + + Video URLs + + + +

Hosts:

+
    + + + +

    Controls:

    +
    + +
    + + + + +
    + +

    Collected Video URLs:

    +
      + + + + \ No newline at end of file diff --git a/clients/chrome/popup.js b/clients/chrome/popup.js new file mode 100644 index 0000000..7b3f75f --- /dev/null +++ b/clients/chrome/popup.js @@ -0,0 +1,228 @@ +document.addEventListener('DOMContentLoaded', function() { + updateUrlList(); + updateHostList(); + updateVolume(); + updatePlaybackState(); + + document.getElementById('clearAll').addEventListener('click', function() { + chrome.runtime.sendMessage({ action: 'clearAll' }); + }); + + document.getElementById('addHost').addEventListener('click', function() { + const host = prompt('Enter new host (ip:port):'); + if (host) { + chrome.runtime.sendMessage({ action: 'addHost', host: host }); + } + }); + + chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { + if (request.action === 'updateUrls') { + updateUrlList(); + } else if (request.action === 'updateHosts') { + updateHostList(); + } else if (request.action == 'updateVolume') { + updateVolume(); + } else if (request.action == 'updatePlaybackState') { + updatePlaybackState(); + } + }); +}); + +function updateUrlList() { + console.log("updateUrlList"); + + chrome.runtime.sendMessage({ action: 'getUrls' }, function(response) { + console.log("getUrls response", response); + + const urlList = document.getElementById('urlList'); + urlList.innerHTML = ''; + response.urls.forEach(url => { + const listItem = document.createElement('li'); + listItem.classList.add('url-item'); + + const urlText = document.createElement('div'); + urlText.textContent = url.url; + + const buttonContainer = document.createElement('div'); + buttonContainer.classList.add('action-buttons'); + + const castButton = document.createElement('button'); + castButton.textContent = 'C'; + castButton.disabled = !response.selectedHost; + castButton.addEventListener('click', function() { + if (response.selectedHost) { + chrome.runtime.sendMessage({ action: 'castVideo', url }); + } + }); + buttonContainer.appendChild(castButton); + + listItem.appendChild(urlText); + listItem.appendChild(buttonContainer); + + urlList.appendChild(listItem); + }); + }); +} + +function updateHostList() { + console.log("updateHostList"); + + chrome.runtime.sendMessage({ action: 'getHosts' }, function(response) { + console.log("getHosts response", response); + + const hostList = document.getElementById('hostList'); + hostList.innerHTML = ''; + console.log("response.hosts", response.hosts); + response.hosts.forEach(host => { + const listItem = document.createElement('li'); + if (host === response.selectedHost) { + listItem.style.color = 'green'; + } + + listItem.style.display = 'flex'; + listItem.style.justifyContent = 'space-between'; + listItem.style.alignItems = 'center'; + + const hostText = document.createElement('span'); + hostText.textContent = host; + hostText.style.flexGrow = 1; + listItem.appendChild(hostText); + + const selectButton = document.createElement('button'); + if (host === response.selectedHost) { + selectButton.textContent = 'Unselect'; + selectButton.classList.add('button-red'); + selectButton.addEventListener('click', function() { + chrome.runtime.sendMessage({ action: 'selectHost', host: null }); + }); + } else { + selectButton.textContent = 'Select'; + selectButton.addEventListener('click', function() { + chrome.runtime.sendMessage({ action: 'selectHost', host: host }); + }); + } + listItem.appendChild(selectButton); + + const deleteButton = document.createElement('button'); + deleteButton.textContent = 'Delete'; + deleteButton.addEventListener('click', function() { + chrome.runtime.sendMessage({ action: 'deleteHost', host: host }); + }); + listItem.appendChild(deleteButton); + + hostList.appendChild(listItem); + }); + + const controlsDiv = document.getElementById('timeBarControls'); + const timeBar = document.getElementById('timeBar'); + const resumeButton = document.getElementById('resumeButton'); + const pauseButton = document.getElementById('pauseButton'); + const stopButton = document.getElementById('stopButton'); + const volumeControl = document.getElementById('volumeControl'); + + if (response.selectedHost) { + controlsDiv.style.opacity = 1; + timeBar.disabled = false; + resumeButton.disabled = false; + pauseButton.disabled = false; + stopButton.disabled = false; + volumeControl.disabled = false; + + timeBar.addEventListener('input', handleSeek); + resumeButton.addEventListener('click', handleResume); + pauseButton.addEventListener('click', handlePause); + stopButton.addEventListener('click', handleStop); + volumeControl.addEventListener('input', handleVolumeChanged); + } else { + controlsDiv.style.opacity = 0.5; + timeBar.disabled = true; + resumeButton.disabled = true; + pauseButton.disabled = true; + stopButton.disabled = true; + volumeControl.disabled = true; + + timeBar.removeEventListener('input', handleSeek); + resumeButton.removeEventListener('click', handleResume); + pauseButton.removeEventListener('click', handlePause); + stopButton.removeEventListener('click', handleStop); + volumeControl.removeEventListener('input', handleVolumeChanged); + } + }); +} + +function updateVolume() { + console.log("updateVolume"); + + chrome.runtime.sendMessage({ action: 'getVolume' }, function (response) { + const volumeControl = document.getElementById('volumeControl'); + if (response.volume) { + volumeControl.value = response.volume * 100; + } else { + volumeControl.disabled = true; + } + }); +} + +function updatePlaybackState() { + console.log("updatePlaybackState"); + + chrome.runtime.sendMessage({ action: 'getPlaybackState' }, function (response) { + const timeBar = document.getElementById('timeBar'); + const resumeButton = document.getElementById('resumeButton'); + const pauseButton = document.getElementById('pauseButton'); + const stopButton = document.getElementById('stopButton'); + const volumeControl = document.getElementById('volumeControl'); + + if (!response.selectedHost || !response.playbackState || response.playbackState.state === 0) { + resumeButton.disabled = true; + pauseButton.disabled = true; + stopButton.disabled = true; + timeBar.disabled = true; + volumeControl.disabled = true; + return; + } + + timeBar.max = response.playbackState.duration * 1000; + timeBar.value = response.playbackState.time * 1000; + + stopButton.disabled = false; + timeBar.disabled = false; + volumeControl.disabled = false; + + switch (response.playbackState.state) { + case 1: // Playing + resumeButton.disabled = true; + pauseButton.disabled = false; + break; + case 2: // Paused + resumeButton.disabled = false; + pauseButton.disabled = true; + break; + } + }); +} + +function handleSeek(event) { + console.log("handleSeek", event); + chrome.runtime.sendMessage({ action: 'seek', time: parseFloat(event.target.value) / 1000.0 }); +} + +function handleResume(event) { + console.log("handleResume", event); + chrome.runtime.sendMessage({ action: 'resume' }); +} + +function handlePause(event) { + console.log("handlePause", event); + chrome.runtime.sendMessage({ action: 'pause' }); +} + +function handleStop(event) { + console.log("handleStop", event); + chrome.runtime.sendMessage({ action: 'stop' }); +} + +function handleVolumeChanged(event) { + console.log("handleVolumeChanged", event); + chrome.runtime.sendMessage({ action: 'setVolume', volume: parseFloat(event.target.value) / 100.0 }); +} \ No newline at end of file diff --git a/clients/terminal/src/main.rs b/clients/terminal/src/main.rs index e95908e..d6b6167 100644 --- a/clients/terminal/src/main.rs +++ b/clients/terminal/src/main.rs @@ -146,7 +146,7 @@ fn run() -> Result<(), Box> { session.send_message(Opcode::Play, Some(play_message))?; } else if let Some(seek_matches) = matches.subcommand_matches("seek") { let seek_message = SeekMessage::new(match seek_matches.value_of("timestamp") { - Some(s) => s.parse::()?, + Some(s) => s.parse::()?, _ => return Err("Timestamp is required.".into()) }); println!("Sent seek {:?}", seek_message); diff --git a/clients/terminal/src/models.rs b/clients/terminal/src/models.rs index 78ec109..ffaaf8a 100644 --- a/clients/terminal/src/models.rs +++ b/clients/terminal/src/models.rs @@ -16,18 +16,19 @@ impl PlayMessage { #[derive(Serialize, Debug)] pub struct SeekMessage { - pub time: u64, + pub time: f64, } impl SeekMessage { - pub fn new(time: u64) -> Self { + pub fn new(time: f64) -> Self { Self { time } } } #[derive(Deserialize, Debug)] pub struct PlaybackUpdateMessage { - pub time: u64, + pub time: f64, + pub duration: f64, pub state: u8 //0 = None, 1 = Playing, 2 = Paused } diff --git a/receivers/android/app/build.gradle b/receivers/android/app/build.gradle index 0bd4976..5aae9bf 100644 --- a/receivers/android/app/build.gradle +++ b/receivers/android/app/build.gradle @@ -85,6 +85,7 @@ dependencies { implementation 'com.google.android.exoplayer:exoplayer:2.18.6' implementation "com.squareup.okhttp3:okhttp:4.11.0" implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + implementation 'org.java-websocket:Java-WebSocket:1.5.4' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/receivers/android/app/src/main/AndroidManifest.xml b/receivers/android/app/src/main/AndroidManifest.xml index 0413338..7951057 100644 --- a/receivers/android/app/src/main/AndroidManifest.xml +++ b/receivers/android/app/src/main/AndroidManifest.xml @@ -54,7 +54,7 @@ diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/BootReceiver.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/BootReceiver.kt index f1d6618..1a5a801 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/BootReceiver.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/BootReceiver.kt @@ -22,7 +22,7 @@ class BootReceiver : BroadcastReceiver() { showStartServiceNotification(context); } else { // Directly start the service for older versions - val serviceIntent = Intent(context, TcpListenerService::class.java) + val serviceIntent = Intent(context, NetworkService::class.java) context.startService(serviceIntent) } } @@ -53,7 +53,7 @@ class BootReceiver : BroadcastReceiver() { } // PendingIntent to start the TcpListenerService - val serviceIntent = Intent(context, TcpListenerService::class.java) + val serviceIntent = Intent(context, NetworkService::class.java) val pendingIntent = PendingIntent.getService(context, 0, serviceIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val startServiceAction = NotificationCompat.Action.Builder(0, "Start Service", pendingIntent).build() diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/DiscoveryService.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/DiscoveryService.kt index 477357e..d5d1cd0 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/DiscoveryService.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/DiscoveryService.kt @@ -1,5 +1,6 @@ package com.futo.fcast.receiver +import WebSocketListenerService import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo @@ -7,7 +8,8 @@ import android.util.Log class DiscoveryService(private val _context: Context) { private var _nsdManager: NsdManager? = null - private val _serviceType = "_fcast._tcp" + private val _registrationListenerTcp = DefaultRegistrationListener() + private val _registrationListenerWs = DefaultRegistrationListener() private fun getDeviceName(): String { return "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}" @@ -20,23 +22,28 @@ class DiscoveryService(private val _context: Context) { Log.i("DiscoveryService", "Discovery service started. Name: $serviceName") _nsdManager = _context.getSystemService(Context.NSD_SERVICE) as NsdManager - val serviceInfo = NsdServiceInfo().apply { + _nsdManager?.registerService(NsdServiceInfo().apply { this.serviceName = serviceName - this.serviceType = _serviceType - this.port = 46899 - } + this.serviceType = "_fcast._tcp" + this.port = TcpListenerService.PORT + }, NsdManager.PROTOCOL_DNS_SD, _registrationListenerTcp) - _nsdManager?.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener) + _nsdManager?.registerService(NsdServiceInfo().apply { + this.serviceName = serviceName + this.serviceType = "_fcast._ws" + this.port = WebSocketListenerService.PORT + }, NsdManager.PROTOCOL_DNS_SD, _registrationListenerWs) } fun stop() { if (_nsdManager == null) return - _nsdManager?.unregisterService(registrationListener) + _nsdManager?.unregisterService(_registrationListenerTcp) + _nsdManager?.unregisterService(_registrationListenerWs) _nsdManager = null } - private val registrationListener = object : NsdManager.RegistrationListener { + private class DefaultRegistrationListener : NsdManager.RegistrationListener { override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { Log.d("DiscoveryService", "Service registered: ${serviceInfo.serviceName}") } diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt index cef8405..08fb97b 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt @@ -4,9 +4,10 @@ import android.util.Log import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.io.DataInputStream import java.io.DataOutputStream +import java.io.OutputStream import java.net.Socket +import java.net.SocketAddress import java.nio.ByteBuffer enum class SessionState { @@ -31,12 +32,12 @@ enum class Opcode(val value: Byte) { const val LENGTH_BYTES = 4 const val MAXIMUM_PACKET_LENGTH = 32000 -class FCastSession(private val _socket: Socket, private val _service: TcpListenerService) { +class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress: SocketAddress, private val _service: NetworkService) { private var _buffer = ByteArray(MAXIMUM_PACKET_LENGTH) private var _bytesRead = 0 private var _packetLength = 0 private var _state = SessionState.WaitingForLength - private var _outputStream: DataOutputStream? = DataOutputStream(_socket.outputStream) + private var _outputStream: DataOutputStream? = DataOutputStream(outputStream) fun sendPlaybackUpdate(value: PlaybackUpdateMessage) { send(Opcode.PlaybackUpdate, value) @@ -82,6 +83,24 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene Log.d(TAG, "Sent $size bytes: '$jsonString'.") } catch (e: Throwable) { Log.i(TAG, "Failed to send message.", e) + throw e + } + } + + fun processBytes(data: ByteBuffer) { + Log.i(TAG, "${data.remaining()} bytes received from ${_remoteSocketAddress}") + if (!data.hasArray()) { + throw IllegalArgumentException("ByteBuffer does not have a backing array") + } + + val byteArray = data.array() + val offset = data.arrayOffset() + data.position() + val length = data.remaining() + + when (_state) { + SessionState.WaitingForLength -> handleLengthBytes(byteArray, offset, length) + SessionState.WaitingForData -> handlePacketBytes(byteArray, offset, length) + else -> throw Exception("Invalid state $_state encountered") } } @@ -90,7 +109,7 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene return } - Log.i(TAG, "$count bytes received from ${_socket.remoteSocketAddress}") + Log.i(TAG, "$count bytes received from ${_remoteSocketAddress}") when (_state) { SessionState.WaitingForLength -> handleLengthBytes(data, 0, count) @@ -116,17 +135,15 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene ((_buffer[3].toInt() and 0xff) shl 24) _bytesRead = 0 - Log.i(TAG, "Packet length header received from ${_socket.remoteSocketAddress}: $_packetLength") + Log.i(TAG, "Packet length header received from ${_remoteSocketAddress}: $_packetLength") if (_packetLength > MAXIMUM_PACKET_LENGTH) { - Log.i(TAG, "Maximum packet length is 32kB, killing socket ${_socket.remoteSocketAddress}: $_packetLength") - _socket.close() - _state = SessionState.Disconnected - return + Log.i(TAG, "Maximum packet length is 32kB, killing socket ${_remoteSocketAddress}: $_packetLength") + throw Exception("Maximum packet length is 32kB") } if (bytesRemaining > 0) { - Log.i(TAG, "$bytesRemaining remaining bytes ${_socket.remoteSocketAddress} pushed to handlePacketBytes") + Log.i(TAG, "$bytesRemaining remaining bytes ${_remoteSocketAddress} pushed to handlePacketBytes") handlePacketBytes(data, offset + bytesToRead, bytesRemaining) } } @@ -141,7 +158,7 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene Log.i(TAG, "Read $bytesToRead bytes from packet") if (_bytesRead >= _packetLength) { - Log.i(TAG, "Packet finished receiving from ${_socket.remoteSocketAddress} of $_packetLength bytes.") + Log.i(TAG, "Packet finished receiving from ${_remoteSocketAddress} of $_packetLength bytes.") handlePacket() _state = SessionState.WaitingForLength @@ -149,14 +166,14 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene _bytesRead = 0 if (bytesRemaining > 0) { - Log.i(TAG, "$bytesRemaining remaining bytes ${_socket.remoteSocketAddress} pushed to handleLengthBytes") + Log.i(TAG, "$bytesRemaining remaining bytes ${_remoteSocketAddress} pushed to handleLengthBytes") handleLengthBytes(data, offset + bytesToRead, bytesRemaining) } } } private fun handlePacket() { - Log.i(TAG, "Processing packet of $_bytesRead bytes from ${_socket.remoteSocketAddress}") + Log.i(TAG, "Processing packet of $_bytesRead bytes from ${_remoteSocketAddress}") val opcode = Opcode.values().firstOrNull { it.value == _buffer[0] } ?: Opcode.None val body = if (_packetLength > 1) _buffer.copyOfRange(1, _packetLength) diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt index 3224de2..7a9bb96 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt @@ -1,5 +1,6 @@ package com.futo.fcast.receiver +import WebSocketListenerService import android.Manifest import android.app.AlertDialog import android.app.PendingIntent @@ -102,7 +103,7 @@ class MainActivity : AppCompatActivity() { _lastDemoToast?.cancel() _lastDemoToast = Toast.makeText(this, "Click $remainingClicks more times to start demo", Toast.LENGTH_SHORT).apply { show() } } else if (_demoClickCount == 5) { - TcpListenerService.instance?.onCastPlay(PlayMessage("video/mp4", "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")) + NetworkService.instance?.onCastPlay(PlayMessage("video/mp4", "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")) _demoClickCount = 0 } } @@ -123,16 +124,18 @@ class MainActivity : AppCompatActivity() { } val ips = getIPs() - _textIPs.text = "IPs\n" + ips.joinToString("\n") + "\n\nPort\n46899" + _textIPs.text = "IPs\n" + ips.joinToString("\n") + "\n\nPorts\n${TcpListenerService.PORT} (TCP), ${WebSocketListenerService.PORT} (WS)" try { val barcodeEncoder = BarcodeEncoder() val px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100.0f, resources.displayMetrics).toInt() - val json = Json.encodeToString(FCastNetworkConfig(ips, listOf( - FCastService(46899, 0) + val json = Json.encodeToString(FCastNetworkConfig("${Build.MANUFACTURER}-${Build.MODEL}", ips, listOf( + FCastService(TcpListenerService.PORT, 0), + FCastService(WebSocketListenerService.PORT, 1) ))) - val base64 = Base64.encode(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) val url = "fcast://r/${base64}" + Log.i(TAG, "connection url: $url") val bitmap = barcodeEncoder.encodeBitmap(url, BarcodeFormat.QR_CODE, px, px) _imageQr.setImageBitmap(bitmap) } catch (e: java.lang.Exception) { @@ -140,7 +143,7 @@ class MainActivity : AppCompatActivity() { _imageQr.visibility = View.GONE } - TcpListenerService.activityCount++ + NetworkService.activityCount++ checkAndRequestPermissions() if (savedInstanceState == null) { @@ -167,7 +170,7 @@ class MainActivity : AppCompatActivity() { InstallReceiver.onReceiveResult = null _scope.cancel() _player.release() - TcpListenerService.activityCount-- + NetworkService.activityCount-- } override fun onSaveInstanceState(outState: Bundle) { @@ -176,12 +179,12 @@ class MainActivity : AppCompatActivity() { } private fun restartService() { - val i = TcpListenerService.instance + val i = NetworkService.instance if (i != null) { i.stopSelf() } - startService(Intent(this, TcpListenerService::class.java)) + startService(Intent(this, NetworkService::class.java)) } private fun startVideo() { @@ -535,7 +538,8 @@ class MainActivity : AppCompatActivity() { continue } - Log.i(TcpListenerService.TAG, "Running on ${addr.hostAddress}:${TcpListenerService.PORT}") + Log.i(TAG, "Running on ${addr.hostAddress}:${TcpListenerService.PORT} (TCP)") + Log.i(TAG, "Running on ${addr.hostAddress}:${WebSocketListenerService.PORT} (WebSocket)") addr.hostAddress?.let { ips.add(it) } } } diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/Models.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/Models.kt index cc2be4f..6ba5046 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/Models.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/Models.kt @@ -4,7 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class FCastNetworkConfig( - val ips: List, + val name: String, + val addresses: List, val services: List ) diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt new file mode 100644 index 0000000..e43cf89 --- /dev/null +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt @@ -0,0 +1,285 @@ +package com.futo.fcast.receiver + +import WebSocketListenerService +import android.app.* +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.* + +class NetworkService : Service() { + private var _discoveryService: DiscoveryService? = null + private var _tcpListenerService: TcpListenerService? = null + private var _webSocketListenerService: WebSocketListenerService? = null + private var _scope: CoroutineScope? = null + private var _stopped = false + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (instance != null) { + throw Exception("Do not start service when already running") + } + + instance = this + + Log.i(TAG, "Starting ListenerService") + + _scope = CoroutineScope(Dispatchers.Main) + _stopped = false + + val name = "Network Listener Service" + val descriptionText = "Listening on port ${TcpListenerService.PORT} (TCP) and port ${WebSocketListenerService.PORT} (Websocket)" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + + val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + val notification: Notification = createNotificationBuilder() + .setContentTitle(name) + .setContentText(descriptionText) + .setSmallIcon(R.mipmap.ic_launcher) + .build() + + startForeground(NOTIFICATION_ID, notification) + + val onNewSession: (FCastSession) -> Unit = { session -> + _scope?.launch(Dispatchers.Main) { + var encounteredError = false + while (!_stopped && !encounteredError) { + try { + val player = PlayerActivity.instance + val updateMessage = if (player != null) { + PlaybackUpdateMessage( + player.currentPosition / 1000.0, + player.duration / 1000.0, + if (player.isPlaying) 1 else 2 + ) + } else { + PlaybackUpdateMessage( + 0.0, + 0.0, + 0 + ) + } + + withContext(Dispatchers.IO) { + try { + session.sendPlaybackUpdate(updateMessage) + } catch (eSend: Throwable) { + Log.e(TAG, "Unhandled error sending update", eSend) + encounteredError = true + return@withContext + } + + Log.i(TAG, "Update sent") + } + } catch (eTimer: Throwable) { + Log.e(TAG, "Unhandled error on timer thread", eTimer) + } finally { + delay(1000) + } + } + } + } + + _discoveryService = DiscoveryService(this).apply { + start() + } + + _tcpListenerService = TcpListenerService(this, onNewSession).apply { + start() + } + + _webSocketListenerService = WebSocketListenerService(this, onNewSession).apply { + start() + } + + Log.i(TAG, "Started NetworkService") + Toast.makeText(this, "Started FCast service", Toast.LENGTH_LONG).show() + + return START_STICKY + } + + private fun createNotificationBuilder(): NotificationCompat.Builder { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationCompat.Builder(this, CHANNEL_ID) + } else { + // For pre-Oreo, do not specify the channel ID + NotificationCompat.Builder(this) + } + } + + override fun onDestroy() { + super.onDestroy() + + Log.i(TAG, "Stopped NetworkService") + + _stopped = true + + _discoveryService?.stop() + _discoveryService = null + + _tcpListenerService?.stop() + _tcpListenerService = null + + try { + _webSocketListenerService?.stop() + } catch (e: Throwable) { + //Ignored + } finally { + _webSocketListenerService = null + } + + _scope?.cancel() + _scope = null + + Toast.makeText(this, "Stopped FCast service", Toast.LENGTH_LONG).show() + instance = null + } + + fun sendCastVolumeUpdate(value: VolumeUpdateMessage) { + _tcpListenerService?.forEachSession { session -> + _scope?.launch { + try { + session.sendVolumeUpdate(value) + } catch (e: Throwable) { + Log.w(TAG, "Failed to send volume update", e) + } + } + } + + _webSocketListenerService?.forEachSession { session -> + _scope?.launch { + try { + session.sendVolumeUpdate(value) + } catch (e: Throwable) { + Log.w(TAG, "Failed to send volume update", e) + } + } + } + } + + fun onCastPlay(playMessage: PlayMessage) { + Log.i(TAG, "onPlay") + + _scope?.launch { + try { + if (PlayerActivity.instance == null) { + val i = Intent(this@NetworkService, PlayerActivity::class.java) + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + i.putExtra("container", playMessage.container) + i.putExtra("url", playMessage.url) + i.putExtra("content", playMessage.content) + i.putExtra("time", playMessage.time) + + if (activityCount > 0) { + startActivity(i) + } else if (Settings.canDrawOverlays(this@NetworkService)) { + val pi = PendingIntent.getActivity(this@NetworkService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + pi.send() + } else { + val pi = PendingIntent.getActivity(this@NetworkService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val playNotification = createNotificationBuilder() + .setContentTitle("FCast") + .setContentText("New content received. Tap to play.") + .setSmallIcon(R.drawable.ic_launcher_background) + .setContentIntent(pi) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(PLAY_NOTIFICATION_ID, playNotification) + } + } else { + PlayerActivity.instance?.play(playMessage) + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to play", e) + } + } + } + + fun onCastPause() { + Log.i(TAG, "onPause") + + _scope?.launch { + try { + PlayerActivity.instance?.pause() + } catch (e: Throwable) { + Log.e(TAG, "Failed to pause", e) + } + } + } + + fun onCastResume() { + Log.i(TAG, "onResume") + + _scope?.launch { + try { + PlayerActivity.instance?.resume() + } catch (e: Throwable) { + Log.e(TAG, "Failed to resume", e) + } + } + } + + fun onCastStop() { + Log.i(TAG, "onStop") + + _scope?.launch { + try { + PlayerActivity.instance?.finish() + } catch (e: Throwable) { + Log.e(TAG, "Failed to stop", e) + } + } + } + + fun onCastSeek(seekMessage: SeekMessage) { + Log.i(TAG, "onSeek") + + _scope?.launch { + try { + PlayerActivity.instance?.seek(seekMessage) + } catch (e: Throwable) { + Log.e(TAG, "Failed to seek", e) + } + } + } + + fun onSetVolume(setVolumeMessage: SetVolumeMessage) { + Log.i(TAG, "onSetVolume") + + _scope?.launch { + try { + PlayerActivity.instance?.setVolume(setVolumeMessage) + } catch (e: Throwable) { + Log.e(TAG, "Failed to seek", e) + } + } + } + + companion object { + private const val CHANNEL_ID = "NetworkListenerServiceChannel" + private const val NOTIFICATION_ID = 1 + private const val PLAY_NOTIFICATION_ID = 2 + private const val TAG = "NetworkService" + var activityCount = 0 + var instance: NetworkService? = null + } +} diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt index 33c39b1..a6f878c 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt @@ -12,12 +12,13 @@ data class PlayMessage( @Serializable data class SeekMessage( - val time: Long + val time: Double ) @Serializable data class PlaybackUpdateMessage( - val time: Long, + val time: Double, + val duration: Double, val state: Int ) diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt index 14310cc..0b0e707 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt @@ -8,7 +8,6 @@ import android.os.Bundle import android.util.Log import android.view.KeyEvent import android.view.View -import android.view.Window import android.view.WindowInsets import android.view.WindowManager import android.widget.ImageView @@ -19,7 +18,6 @@ import com.google.android.exoplayer2.* import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.dash.DashMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource -import com.google.android.exoplayer2.text.ExoplayerCuesDecoder import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.upstream.DefaultDataSource @@ -41,6 +39,7 @@ class PlayerActivity : AppCompatActivity() { private var _wasPlaying = false val currentPosition get() = _exoPlayer.currentPosition + val duration get() = _exoPlayer.duration val isPlaying get() = _exoPlayer.isPlaying private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() { @@ -111,7 +110,7 @@ class PlayerActivity : AppCompatActivity() { super.onVolumeChanged(volume) _scope.launch(Dispatchers.IO) { try { - TcpListenerService.instance?.sendCastVolumeUpdate(VolumeUpdateMessage(volume.toDouble())) + NetworkService.instance?.sendCastVolumeUpdate(VolumeUpdateMessage(volume.toDouble())) } catch (e: Throwable) { Log.e(TAG, "Unhandled error sending volume update", e) } @@ -167,7 +166,7 @@ class PlayerActivity : AppCompatActivity() { play(PlayMessage(container, url, content, time)) instance = this - TcpListenerService.activityCount++ + NetworkService.activityCount++ } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -242,7 +241,7 @@ class PlayerActivity : AppCompatActivity() { _exoPlayer.removeListener(_playerEventListener) _exoPlayer.stop() _playerControlView.player = null - TcpListenerService.activityCount-- + NetworkService.activityCount-- } override fun dispatchKeyEvent(event: KeyEvent): Boolean { @@ -293,6 +292,7 @@ class PlayerActivity : AppCompatActivity() { val mediaItem = mediaItemBuilder.build() val mediaSource = when (playMessage.container) { "application/dash+xml" -> DashMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) + "application/x-mpegurl" -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) "application/vnd.apple.mpegurl" -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) else -> DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) } @@ -319,7 +319,7 @@ class PlayerActivity : AppCompatActivity() { } fun seek(seekMessage: SeekMessage) { - _exoPlayer.seekTo(seekMessage.time * 1000) + _exoPlayer.seekTo((seekMessage.time * 1000.0).toLong()) } fun setVolume(setVolumeMessage: SetVolumeMessage) { diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt index 40319d7..844872c 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt @@ -1,67 +1,25 @@ package com.futo.fcast.receiver -import android.app.* -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.IBinder -import android.provider.Settings import android.util.Log -import android.widget.Toast -import androidx.core.app.NotificationCompat -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.BufferedInputStream -import java.net.NetworkInterface import java.net.ServerSocket import java.net.Socket -import java.util.* +import java.util.ArrayList -class TcpListenerService : Service() { +class TcpListenerService(private val _networkService: NetworkService, private val _onNewSession: (session: FCastSession) -> Unit) { private var _serverSocket: ServerSocket? = null private var _stopped: Boolean = false private var _listenThread: Thread? = null private var _clientThreads: ArrayList = arrayListOf() private var _sessions: ArrayList = arrayListOf() - private var _discoveryService: DiscoveryService? = null - private var _scope: CoroutineScope? = null - override fun onBind(intent: Intent?): IBinder? { - return null - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (instance != null) { - throw Exception("Do not start service when already running") - } - - instance = this - - Log.i(TAG, "Starting ListenerService") - - _scope = CoroutineScope(Dispatchers.Main) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = "TCP Listener Service" - val descriptionText = "Listening on port $PORT" - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { - description = descriptionText - } - - val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - - val notification: Notification = createNotificationBuilder() - .setContentTitle("TCP Listener Service") - .setContentText("Listening on port $PORT") - .setSmallIcon(R.mipmap.ic_launcher) // Ensure this icon exists - .build() - - startForeground(NOTIFICATION_ID, notification) - - _discoveryService = DiscoveryService(this) - _discoveryService?.start() + fun start() { + Log.i(TAG, "Starting TcpListenerService") _listenThread = Thread { Log.i(TAG, "Starting listener") @@ -75,58 +33,14 @@ class TcpListenerService : Service() { _listenThread?.start() - _scope?.launch(Dispatchers.Main) { - while (!_stopped) { - try { - val player = PlayerActivity.instance - if (player != null) { - val updateMessage = PlaybackUpdateMessage( - player.currentPosition / 1000, - if (player.isPlaying) 1 else 2 - ) - - withContext(Dispatchers.IO) { - try { - sendCastPlaybackUpdate(updateMessage) - } catch (eSend: Throwable) { - Log.e(TAG, "Unhandled error sending update", eSend) - } - - Log.i(TAG, "Update sent") - } - } - } catch (eTimer: Throwable) { - Log.e(TAG, "Unhandled error on timer thread", eTimer) - } finally { - delay(1000) - } - } - } - - Log.i(TAG, "Started ListenerService") - Toast.makeText(this, "Started FCast service", Toast.LENGTH_LONG).show() - - return START_STICKY + Log.i(TAG, "Started TcpListenerService") } - private fun createNotificationBuilder(): NotificationCompat.Builder { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationCompat.Builder(this, CHANNEL_ID) - } else { - // For pre-Oreo, do not specify the channel ID - NotificationCompat.Builder(this) - } - } + fun stop() { + Log.i(TAG, "Stopping TcpListenerService") - override fun onDestroy() { - super.onDestroy() - - Log.i(TAG, "Stopped ListenerService") _stopped = true - _discoveryService?.stop() - _discoveryService = null - _serverSocket?.close() _serverSocket = null @@ -137,134 +51,13 @@ class TcpListenerService : Service() { _clientThreads.clear() } - _scope?.cancel() - _scope = null - - Toast.makeText(this, "Stopped FCast service", Toast.LENGTH_LONG).show() - instance = null + Log.i(TAG, "Stopped TcpListenerService") } - private fun sendCastPlaybackUpdate(value: PlaybackUpdateMessage) { + fun forEachSession(handler: (FCastSession) -> Unit) { synchronized(_sessions) { for (session in _sessions) { - try { - session.sendPlaybackUpdate(value) - } catch (e: Throwable) { - Log.w(TAG, "Failed to send playback update", e) - } - } - } - } - - fun sendCastVolumeUpdate(value: VolumeUpdateMessage) { - synchronized(_sessions) { - for (session in _sessions) { - try { - session.sendVolumeUpdate(value) - } catch (e: Throwable) { - Log.w(TAG, "Failed to send volume update", e) - } - } - } - } - - fun onCastPlay(playMessage: PlayMessage) { - Log.i(TAG, "onPlay") - - _scope?.launch { - try { - if (PlayerActivity.instance == null) { - val i = Intent(this@TcpListenerService, PlayerActivity::class.java) - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - i.putExtra("container", playMessage.container) - i.putExtra("url", playMessage.url) - i.putExtra("content", playMessage.content) - i.putExtra("time", playMessage.time) - - if (activityCount > 0) { - startActivity(i) - } else if (Settings.canDrawOverlays(this@TcpListenerService)) { - val pi = PendingIntent.getActivity(this@TcpListenerService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - pi.send() - } else { - val pi = PendingIntent.getActivity(this@TcpListenerService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val playNotification = createNotificationBuilder() - .setContentTitle("FCast") - .setContentText("New content received. Tap to play.") - .setSmallIcon(R.drawable.ic_launcher_background) - .setContentIntent(pi) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .build() - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(PLAY_NOTIFICATION_ID, playNotification) - } - } else { - PlayerActivity.instance?.play(playMessage) - } - } catch (e: Throwable) { - Log.e(TAG, "Failed to play", e) - } - } - } - - fun onCastPause() { - Log.i(TAG, "onPause") - - _scope?.launch { - try { - PlayerActivity.instance?.pause() - } catch (e: Throwable) { - Log.e(TAG, "Failed to pause", e) - } - } - } - - fun onCastResume() { - Log.i(TAG, "onResume") - - _scope?.launch { - try { - PlayerActivity.instance?.resume() - } catch (e: Throwable) { - Log.e(TAG, "Failed to resume", e) - } - } - } - - fun onCastStop() { - Log.i(TAG, "onStop") - - _scope?.launch { - try { - PlayerActivity.instance?.finish() - } catch (e: Throwable) { - Log.e(TAG, "Failed to stop", e) - } - } - } - - fun onCastSeek(seekMessage: SeekMessage) { - Log.i(TAG, "onSeek") - - _scope?.launch { - try { - PlayerActivity.instance?.seek(seekMessage) - } catch (e: Throwable) { - Log.e(TAG, "Failed to seek", e) - } - } - } - - fun onSetVolume(setVolumeMessage: SetVolumeMessage) { - Log.i(TAG, "onSetVolume") - - _scope?.launch { - try { - PlayerActivity.instance?.setVolume(setVolumeMessage) - } catch (e: Throwable) { - Log.e(TAG, "Failed to seek", e) + handler(session) } } } @@ -298,10 +91,11 @@ class TcpListenerService : Service() { private fun handleClientConnection(socket: Socket) { Log.i(TAG, "New connection received from ${socket.remoteSocketAddress}") - val session = FCastSession(socket, this) + val session = FCastSession(socket.getOutputStream(), socket.remoteSocketAddress, _networkService) synchronized(_sessions) { _sessions.add(session) } + _onNewSession(session) Log.i(TAG, "Waiting for data from ${socket.remoteSocketAddress}") @@ -333,12 +127,7 @@ class TcpListenerService : Service() { } companion object { - const val PORT = 46899 - const val CHANNEL_ID = "TcpListenerServiceChannel" - const val NOTIFICATION_ID = 1 - const val PLAY_NOTIFICATION_ID = 2 const val TAG = "TcpListenerService" - var activityCount = 0 - var instance: TcpListenerService? = null + const val PORT = 46899 } -} +} \ No newline at end of file diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketListenerService.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketListenerService.kt new file mode 100644 index 0000000..db291a6 --- /dev/null +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketListenerService.kt @@ -0,0 +1,69 @@ +import android.util.Log +import com.futo.fcast.receiver.FCastSession +import com.futo.fcast.receiver.NetworkService +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.util.IdentityHashMap + +class WebSocketListenerService(private val _networkService: NetworkService, private val _onNewSession: (session: FCastSession) -> Unit) : WebSocketServer(InetSocketAddress(PORT)) { + private var _sessions = IdentityHashMap() + + override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { + val session = FCastSession(WebSocketOutputStream(conn), conn.remoteSocketAddress, _networkService) + synchronized(_sessions) { + _sessions[conn] = session + } + _onNewSession(session) + + Log.i(TAG, "New connection from ${conn.remoteSocketAddress}") + } + + override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) { + synchronized(_sessions) { + _sessions.remove(conn) + } + + Log.i(TAG, "Closed connection from ${conn.remoteSocketAddress}") + } + + override fun onMessage(conn: WebSocket?, message: String?) { + Log.i(TAG, "Received string message, but not processing: $message") + } + + override fun onMessage(conn: WebSocket?, message: ByteBuffer?) { + if (message == null) { + Log.i(TAG, "Received byte message null") + return + } + + Log.i(TAG, "Received byte message (offset = ${message.arrayOffset()}, size = ${message.remaining()})") + + synchronized(_sessions) { + _sessions[conn]?.processBytes(message) + } + } + + override fun onError(conn: WebSocket?, ex: Exception) { + Log.e(TAG, "Error in WebSocket connection", ex) + } + + override fun onStart() { + Log.i(TAG, "WebSocketListenerService started on port $PORT") + } + + fun forEachSession(handler: (FCastSession) -> Unit) { + synchronized(_sessions) { + for (pair in _sessions) { + handler(pair.value) + } + } + } + + companion object { + const val TAG = "WebSocketListenerService" + const val PORT = 46898 + } +} \ No newline at end of file diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketOutputStream.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketOutputStream.kt new file mode 100644 index 0000000..3381463 --- /dev/null +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/WebSocketOutputStream.kt @@ -0,0 +1,21 @@ +import org.java_websocket.WebSocket +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer + +class WebSocketOutputStream(private val _webSocket: WebSocket) : OutputStream() { + @Throws(IOException::class) + override fun write(b: Int) { + write(byteArrayOf(b.toByte()), 0, 1) + } + + @Throws(IOException::class) + override fun write(b: ByteArray, off: Int, len: Int) { + _webSocket.send(ByteBuffer.wrap(b, off, len)) + } + + @Throws(IOException::class) + override fun close() { + _webSocket.close() + } +} \ No newline at end of file