1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-06-24 21:25:23 +00:00

Added websocket wrapper for TCP connection to Android receiver.

This commit is contained in:
Koen 2023-12-06 09:04:14 +01:00
parent b339f4f487
commit ad8f3985a3
22 changed files with 1165 additions and 277 deletions

View file

@ -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: ["<all_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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

View file

@ -0,0 +1,23 @@
{
"manifest_version": 3,
"name": "Video URL Collector",
"version": "1.0",
"permissions": [
"webRequest",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
}

66
clients/chrome/popup.html Normal file
View file

@ -0,0 +1,66 @@
<!doctype html>
<html>
<head>
<title>Video URLs</title>
<style>
body { font-family: Arial, sans-serif; margin: 10px; width: 400px; }
ul { list-style-type: none; padding: 0; }
li { margin: 5px 0; word-break: break-all; }
button {
margin: 5px;
padding: 5px 10px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.button-red {
background-color: #AF4C50;
}
button:disabled {
background-color: #333333;
}
.host-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.action-buttons {
margin-left: 10px;
}
.url-item {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body>
<h3>Hosts:</h3>
<ul id="hostList"></ul>
<button id="addHost">Add Host</button>
<button id="clearAll">Clear All</button>
<h3>Controls:</h3>
<div id="timeBarControls" style="opacity: 0.5;">
<input type="range" id="timeBar" min="0" max="100" value="0" style="width: 100%;">
<br>
<button id="resumeButton" disabled>Resume</button>
<button id="pauseButton" disabled>Pause</button>
<button id="stopButton" disabled>Stop</button>
<input type="range" id="volumeControl" min="0" max="100" value="50" disabled>
</div>
<h3>Collected Video URLs:</h3>
<ul id="urlList"></ul>
<script src="popup.js"></script>
</body>
</html>

228
clients/chrome/popup.js Normal file
View file

@ -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 });
}

View file

@ -146,7 +146,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
session.send_message(Opcode::Play, Some(play_message))?; session.send_message(Opcode::Play, Some(play_message))?;
} else if let Some(seek_matches) = matches.subcommand_matches("seek") { } else if let Some(seek_matches) = matches.subcommand_matches("seek") {
let seek_message = SeekMessage::new(match seek_matches.value_of("timestamp") { let seek_message = SeekMessage::new(match seek_matches.value_of("timestamp") {
Some(s) => s.parse::<u64>()?, Some(s) => s.parse::<f64>()?,
_ => return Err("Timestamp is required.".into()) _ => return Err("Timestamp is required.".into())
}); });
println!("Sent seek {:?}", seek_message); println!("Sent seek {:?}", seek_message);

View file

@ -16,18 +16,19 @@ impl PlayMessage {
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct SeekMessage { pub struct SeekMessage {
pub time: u64, pub time: f64,
} }
impl SeekMessage { impl SeekMessage {
pub fn new(time: u64) -> Self { pub fn new(time: f64) -> Self {
Self { time } Self { time }
} }
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct PlaybackUpdateMessage { pub struct PlaybackUpdateMessage {
pub time: u64, pub time: f64,
pub duration: f64,
pub state: u8 //0 = None, 1 = Playing, 2 = Paused pub state: u8 //0 = None, 1 = Playing, 2 = Paused
} }

View file

@ -85,6 +85,7 @@ dependencies {
implementation 'com.google.android.exoplayer:exoplayer:2.18.6' implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
implementation "com.squareup.okhttp3:okhttp:4.11.0" implementation "com.squareup.okhttp3:okhttp:4.11.0"
implementation 'com.journeyapps:zxing-android-embedded:4.3.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' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View file

@ -54,7 +54,7 @@
</receiver> </receiver>
<service <service
android:name=".TcpListenerService" android:name=".NetworkService"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false" />

View file

@ -22,7 +22,7 @@ class BootReceiver : BroadcastReceiver() {
showStartServiceNotification(context); showStartServiceNotification(context);
} else { } else {
// Directly start the service for older versions // Directly start the service for older versions
val serviceIntent = Intent(context, TcpListenerService::class.java) val serviceIntent = Intent(context, NetworkService::class.java)
context.startService(serviceIntent) context.startService(serviceIntent)
} }
} }
@ -53,7 +53,7 @@ class BootReceiver : BroadcastReceiver() {
} }
// PendingIntent to start the TcpListenerService // 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 pendingIntent = PendingIntent.getService(context, 0, serviceIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val startServiceAction = NotificationCompat.Action.Builder(0, "Start Service", pendingIntent).build() val startServiceAction = NotificationCompat.Action.Builder(0, "Start Service", pendingIntent).build()

View file

@ -1,5 +1,6 @@
package com.futo.fcast.receiver package com.futo.fcast.receiver
import WebSocketListenerService
import android.content.Context import android.content.Context
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
@ -7,7 +8,8 @@ import android.util.Log
class DiscoveryService(private val _context: Context) { class DiscoveryService(private val _context: Context) {
private var _nsdManager: NsdManager? = null private var _nsdManager: NsdManager? = null
private val _serviceType = "_fcast._tcp" private val _registrationListenerTcp = DefaultRegistrationListener()
private val _registrationListenerWs = DefaultRegistrationListener()
private fun getDeviceName(): String { private fun getDeviceName(): String {
return "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}" 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") Log.i("DiscoveryService", "Discovery service started. Name: $serviceName")
_nsdManager = _context.getSystemService(Context.NSD_SERVICE) as NsdManager _nsdManager = _context.getSystemService(Context.NSD_SERVICE) as NsdManager
val serviceInfo = NsdServiceInfo().apply { _nsdManager?.registerService(NsdServiceInfo().apply {
this.serviceName = serviceName this.serviceName = serviceName
this.serviceType = _serviceType this.serviceType = "_fcast._tcp"
this.port = 46899 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() { fun stop() {
if (_nsdManager == null) return if (_nsdManager == null) return
_nsdManager?.unregisterService(registrationListener) _nsdManager?.unregisterService(_registrationListenerTcp)
_nsdManager?.unregisterService(_registrationListenerWs)
_nsdManager = null _nsdManager = null
} }
private val registrationListener = object : NsdManager.RegistrationListener { private class DefaultRegistrationListener : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
Log.d("DiscoveryService", "Service registered: ${serviceInfo.serviceName}") Log.d("DiscoveryService", "Service registered: ${serviceInfo.serviceName}")
} }

View file

@ -4,9 +4,10 @@ import android.util.Log
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.DataInputStream
import java.io.DataOutputStream import java.io.DataOutputStream
import java.io.OutputStream
import java.net.Socket import java.net.Socket
import java.net.SocketAddress
import java.nio.ByteBuffer import java.nio.ByteBuffer
enum class SessionState { enum class SessionState {
@ -31,12 +32,12 @@ enum class Opcode(val value: Byte) {
const val LENGTH_BYTES = 4 const val LENGTH_BYTES = 4
const val MAXIMUM_PACKET_LENGTH = 32000 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 _buffer = ByteArray(MAXIMUM_PACKET_LENGTH)
private var _bytesRead = 0 private var _bytesRead = 0
private var _packetLength = 0 private var _packetLength = 0
private var _state = SessionState.WaitingForLength private var _state = SessionState.WaitingForLength
private var _outputStream: DataOutputStream? = DataOutputStream(_socket.outputStream) private var _outputStream: DataOutputStream? = DataOutputStream(outputStream)
fun sendPlaybackUpdate(value: PlaybackUpdateMessage) { fun sendPlaybackUpdate(value: PlaybackUpdateMessage) {
send(Opcode.PlaybackUpdate, value) 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'.") Log.d(TAG, "Sent $size bytes: '$jsonString'.")
} catch (e: Throwable) { } catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e) 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 return
} }
Log.i(TAG, "$count bytes received from ${_socket.remoteSocketAddress}") Log.i(TAG, "$count bytes received from ${_remoteSocketAddress}")
when (_state) { when (_state) {
SessionState.WaitingForLength -> handleLengthBytes(data, 0, count) 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) ((_buffer[3].toInt() and 0xff) shl 24)
_bytesRead = 0 _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) { if (_packetLength > MAXIMUM_PACKET_LENGTH) {
Log.i(TAG, "Maximum packet length is 32kB, killing socket ${_socket.remoteSocketAddress}: $_packetLength") Log.i(TAG, "Maximum packet length is 32kB, killing socket ${_remoteSocketAddress}: $_packetLength")
_socket.close() throw Exception("Maximum packet length is 32kB")
_state = SessionState.Disconnected
return
} }
if (bytesRemaining > 0) { 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) 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") Log.i(TAG, "Read $bytesToRead bytes from packet")
if (_bytesRead >= _packetLength) { 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() handlePacket()
_state = SessionState.WaitingForLength _state = SessionState.WaitingForLength
@ -149,14 +166,14 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
_bytesRead = 0 _bytesRead = 0
if (bytesRemaining > 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) handleLengthBytes(data, offset + bytesToRead, bytesRemaining)
} }
} }
} }
private fun handlePacket() { 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 opcode = Opcode.values().firstOrNull { it.value == _buffer[0] } ?: Opcode.None
val body = if (_packetLength > 1) _buffer.copyOfRange(1, _packetLength) val body = if (_packetLength > 1) _buffer.copyOfRange(1, _packetLength)

View file

@ -1,5 +1,6 @@
package com.futo.fcast.receiver package com.futo.fcast.receiver
import WebSocketListenerService
import android.Manifest import android.Manifest
import android.app.AlertDialog import android.app.AlertDialog
import android.app.PendingIntent import android.app.PendingIntent
@ -102,7 +103,7 @@ class MainActivity : AppCompatActivity() {
_lastDemoToast?.cancel() _lastDemoToast?.cancel()
_lastDemoToast = Toast.makeText(this, "Click $remainingClicks more times to start demo", Toast.LENGTH_SHORT).apply { show() } _lastDemoToast = Toast.makeText(this, "Click $remainingClicks more times to start demo", Toast.LENGTH_SHORT).apply { show() }
} else if (_demoClickCount == 5) { } 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 _demoClickCount = 0
} }
} }
@ -123,16 +124,18 @@ class MainActivity : AppCompatActivity() {
} }
val ips = getIPs() 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 { try {
val barcodeEncoder = BarcodeEncoder() val barcodeEncoder = BarcodeEncoder()
val px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100.0f, resources.displayMetrics).toInt() val px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100.0f, resources.displayMetrics).toInt()
val json = Json.encodeToString(FCastNetworkConfig(ips, listOf( val json = Json.encodeToString(FCastNetworkConfig("${Build.MANUFACTURER}-${Build.MODEL}", ips, listOf(
FCastService(46899, 0) 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}" val url = "fcast://r/${base64}"
Log.i(TAG, "connection url: $url")
val bitmap = barcodeEncoder.encodeBitmap(url, BarcodeFormat.QR_CODE, px, px) val bitmap = barcodeEncoder.encodeBitmap(url, BarcodeFormat.QR_CODE, px, px)
_imageQr.setImageBitmap(bitmap) _imageQr.setImageBitmap(bitmap)
} catch (e: java.lang.Exception) { } catch (e: java.lang.Exception) {
@ -140,7 +143,7 @@ class MainActivity : AppCompatActivity() {
_imageQr.visibility = View.GONE _imageQr.visibility = View.GONE
} }
TcpListenerService.activityCount++ NetworkService.activityCount++
checkAndRequestPermissions() checkAndRequestPermissions()
if (savedInstanceState == null) { if (savedInstanceState == null) {
@ -167,7 +170,7 @@ class MainActivity : AppCompatActivity() {
InstallReceiver.onReceiveResult = null InstallReceiver.onReceiveResult = null
_scope.cancel() _scope.cancel()
_player.release() _player.release()
TcpListenerService.activityCount-- NetworkService.activityCount--
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
@ -176,12 +179,12 @@ class MainActivity : AppCompatActivity() {
} }
private fun restartService() { private fun restartService() {
val i = TcpListenerService.instance val i = NetworkService.instance
if (i != null) { if (i != null) {
i.stopSelf() i.stopSelf()
} }
startService(Intent(this, TcpListenerService::class.java)) startService(Intent(this, NetworkService::class.java))
} }
private fun startVideo() { private fun startVideo() {
@ -535,7 +538,8 @@ class MainActivity : AppCompatActivity() {
continue 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) } addr.hostAddress?.let { ips.add(it) }
} }
} }

View file

@ -4,7 +4,8 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class FCastNetworkConfig( data class FCastNetworkConfig(
val ips: List<String>, val name: String,
val addresses: List<String>,
val services: List<FCastService> val services: List<FCastService>
) )

View file

@ -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
}
}

View file

@ -12,12 +12,13 @@ data class PlayMessage(
@Serializable @Serializable
data class SeekMessage( data class SeekMessage(
val time: Long val time: Double
) )
@Serializable @Serializable
data class PlaybackUpdateMessage( data class PlaybackUpdateMessage(
val time: Long, val time: Double,
val duration: Double,
val state: Int val state: Int
) )

View file

@ -8,7 +8,6 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.Window
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import android.widget.ImageView 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.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.dash.DashMediaSource import com.google.android.exoplayer2.source.dash.DashMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource 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.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DefaultDataSource import com.google.android.exoplayer2.upstream.DefaultDataSource
@ -41,6 +39,7 @@ class PlayerActivity : AppCompatActivity() {
private var _wasPlaying = false private var _wasPlaying = false
val currentPosition get() = _exoPlayer.currentPosition val currentPosition get() = _exoPlayer.currentPosition
val duration get() = _exoPlayer.duration
val isPlaying get() = _exoPlayer.isPlaying val isPlaying get() = _exoPlayer.isPlaying
private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() { private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() {
@ -111,7 +110,7 @@ class PlayerActivity : AppCompatActivity() {
super.onVolumeChanged(volume) super.onVolumeChanged(volume)
_scope.launch(Dispatchers.IO) { _scope.launch(Dispatchers.IO) {
try { try {
TcpListenerService.instance?.sendCastVolumeUpdate(VolumeUpdateMessage(volume.toDouble())) NetworkService.instance?.sendCastVolumeUpdate(VolumeUpdateMessage(volume.toDouble()))
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Unhandled error sending volume update", e) Log.e(TAG, "Unhandled error sending volume update", e)
} }
@ -167,7 +166,7 @@ class PlayerActivity : AppCompatActivity() {
play(PlayMessage(container, url, content, time)) play(PlayMessage(container, url, content, time))
instance = this instance = this
TcpListenerService.activityCount++ NetworkService.activityCount++
} }
override fun onWindowFocusChanged(hasFocus: Boolean) { override fun onWindowFocusChanged(hasFocus: Boolean) {
@ -242,7 +241,7 @@ class PlayerActivity : AppCompatActivity() {
_exoPlayer.removeListener(_playerEventListener) _exoPlayer.removeListener(_playerEventListener)
_exoPlayer.stop() _exoPlayer.stop()
_playerControlView.player = null _playerControlView.player = null
TcpListenerService.activityCount-- NetworkService.activityCount--
} }
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@ -293,6 +292,7 @@ class PlayerActivity : AppCompatActivity() {
val mediaItem = mediaItemBuilder.build() val mediaItem = mediaItemBuilder.build()
val mediaSource = when (playMessage.container) { val mediaSource = when (playMessage.container) {
"application/dash+xml" -> DashMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) "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) "application/vnd.apple.mpegurl" -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
else -> DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) else -> DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem)
} }
@ -319,7 +319,7 @@ class PlayerActivity : AppCompatActivity() {
} }
fun seek(seekMessage: SeekMessage) { fun seek(seekMessage: SeekMessage) {
_exoPlayer.seekTo(seekMessage.time * 1000) _exoPlayer.seekTo((seekMessage.time * 1000.0).toLong())
} }
fun setVolume(setVolumeMessage: SetVolumeMessage) { fun setVolume(setVolumeMessage: SetVolumeMessage) {

View file

@ -1,67 +1,25 @@
package com.futo.fcast.receiver 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.util.Log
import android.widget.Toast import kotlinx.coroutines.CoroutineScope
import androidx.core.app.NotificationCompat import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.* import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.net.NetworkInterface
import java.net.ServerSocket import java.net.ServerSocket
import java.net.Socket 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 _serverSocket: ServerSocket? = null
private var _stopped: Boolean = false private var _stopped: Boolean = false
private var _listenThread: Thread? = null private var _listenThread: Thread? = null
private var _clientThreads: ArrayList<Thread> = arrayListOf() private var _clientThreads: ArrayList<Thread> = arrayListOf()
private var _sessions: ArrayList<FCastSession> = arrayListOf() private var _sessions: ArrayList<FCastSession> = arrayListOf()
private var _discoveryService: DiscoveryService? = null
private var _scope: CoroutineScope? = null
override fun onBind(intent: Intent?): IBinder? { fun start() {
return null Log.i(TAG, "Starting TcpListenerService")
}
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()
_listenThread = Thread { _listenThread = Thread {
Log.i(TAG, "Starting listener") Log.i(TAG, "Starting listener")
@ -75,58 +33,14 @@ class TcpListenerService : Service() {
_listenThread?.start() _listenThread?.start()
_scope?.launch(Dispatchers.Main) { Log.i(TAG, "Started TcpListenerService")
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") fun stop() {
} Log.i(TAG, "Stopping TcpListenerService")
}
} 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
}
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 ListenerService")
_stopped = true _stopped = true
_discoveryService?.stop()
_discoveryService = null
_serverSocket?.close() _serverSocket?.close()
_serverSocket = null _serverSocket = null
@ -137,134 +51,13 @@ class TcpListenerService : Service() {
_clientThreads.clear() _clientThreads.clear()
} }
_scope?.cancel() Log.i(TAG, "Stopped TcpListenerService")
_scope = null
Toast.makeText(this, "Stopped FCast service", Toast.LENGTH_LONG).show()
instance = null
} }
private fun sendCastPlaybackUpdate(value: PlaybackUpdateMessage) { fun forEachSession(handler: (FCastSession) -> Unit) {
synchronized(_sessions) { synchronized(_sessions) {
for (session in _sessions) { for (session in _sessions) {
try { handler(session)
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)
} }
} }
} }
@ -298,10 +91,11 @@ class TcpListenerService : Service() {
private fun handleClientConnection(socket: Socket) { private fun handleClientConnection(socket: Socket) {
Log.i(TAG, "New connection received from ${socket.remoteSocketAddress}") Log.i(TAG, "New connection received from ${socket.remoteSocketAddress}")
val session = FCastSession(socket, this) val session = FCastSession(socket.getOutputStream(), socket.remoteSocketAddress, _networkService)
synchronized(_sessions) { synchronized(_sessions) {
_sessions.add(session) _sessions.add(session)
} }
_onNewSession(session)
Log.i(TAG, "Waiting for data from ${socket.remoteSocketAddress}") Log.i(TAG, "Waiting for data from ${socket.remoteSocketAddress}")
@ -333,12 +127,7 @@ class TcpListenerService : Service() {
} }
companion object { 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" const val TAG = "TcpListenerService"
var activityCount = 0 const val PORT = 46899
var instance: TcpListenerService? = null
} }
} }

View file

@ -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<WebSocket, FCastSession>()
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
}
}

View file

@ -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()
}
}