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:
parent
b339f4f487
commit
ad8f3985a3
22 changed files with 1165 additions and 277 deletions
375
clients/chrome/background.js
Normal file
375
clients/chrome/background.js
Normal 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;
|
||||||
|
}
|
BIN
clients/chrome/icons/icon128.png
Normal file
BIN
clients/chrome/icons/icon128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 926 B |
BIN
clients/chrome/icons/icon16.png
Normal file
BIN
clients/chrome/icons/icon16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 489 B |
BIN
clients/chrome/icons/icon48.png
Normal file
BIN
clients/chrome/icons/icon48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 595 B |
23
clients/chrome/manifest.json
Normal file
23
clients/chrome/manifest.json
Normal 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
66
clients/chrome/popup.html
Normal 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
228
clients/chrome/popup.js
Normal 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 });
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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}")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue