Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ plugins {
}

android {
namespace 'org.librecast.receiver'
namespace 'com.m5dev.librecast'
compileSdk 34

defaultConfig {
applicationId "org.librecast.receiver"
applicationId "com.m5dev.librecast"
minSdk 19
targetSdk 34
versionCode 1
Expand All @@ -31,4 +31,7 @@ android {
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.core:core:1.6.0'
implementation 'org.java-websocket:Java-WebSocket:1.5.4'

testImplementation 'junit:junit:4.13.2'
}
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".ReceiverService"
android:exported="false" />
</application>

</manifest>
125 changes: 125 additions & 0 deletions app/src/main/java/com/m5dev/librecast/LibreCastServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.m5dev.librecast;

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;

public class LibreCastServer extends WebSocketServer {

public interface ServerEventListener {
void onServerStarted(int port);
void onServerStopped();
void onClientConnected(String clientIp);
void onClientDisconnected(String clientIp);
void onVideoChunkReceived(ByteBuffer chunk);
void onError(Exception ex);
}

public static final int DEFAULT_PORT = 8080;

private ServerEventListener listener;
private WebSocket activeConnection;

public LibreCastServer(int port) {
super(new InetSocketAddress(port));
setReuseAddr(true);
}

public LibreCastServer(int port, ServerEventListener listener) {
this(port);
this.listener = listener;
}

public void setListener(ServerEventListener listener) {
this.listener = listener;
}

public synchronized WebSocket getActiveConnection() {
return activeConnection;
}

@Override
public synchronized void onOpen(WebSocket conn, ClientHandshake handshake) {
if (activeConnection != null && activeConnection != conn && activeConnection.isOpen()) {
try {
activeConnection.close(1000, "Replaced by new client connection");
} catch (Exception ignored) {
}
}
activeConnection = conn;
String clientIp = getClientIp(conn);
if (listener != null) {
listener.onClientConnected(clientIp);
}
}

@Override
public synchronized void onClose(WebSocket conn, int code, String reason, boolean remote) {
if (conn == activeConnection) {
String clientIp = getClientIp(conn);
activeConnection = null;
if (listener != null) {
listener.onClientDisconnected(clientIp);
}
}
}

@Override
public void onMessage(WebSocket conn, String message) {
// Text messages handled or ignored
}

@Override
public void onMessage(WebSocket conn, ByteBuffer message) {
WebSocket currentActive;
synchronized (this) {
currentActive = activeConnection;
}
if (conn != null && conn == currentActive) {
if (listener != null) {
listener.onVideoChunkReceived(message);
}
}
}

@Override
public void onError(WebSocket conn, Exception ex) {
if (listener != null) {
listener.onError(ex);
}
}

@Override
public void onStart() {
if (listener != null) {
listener.onServerStarted(getPort());
}
}

@Override
public void stop() throws InterruptedException {
synchronized (this) {
if (activeConnection != null) {
try {
activeConnection.close(1000, "Server stopping");
} catch (Exception ignored) {
}
activeConnection = null;
}
}
super.stop();
if (listener != null) {
listener.onServerStopped();
}
}

private String getClientIp(WebSocket conn) {
if (conn != null && conn.getRemoteSocketAddress() != null && conn.getRemoteSocketAddress().getAddress() != null) {
return conn.getRemoteSocketAddress().getAddress().getHostAddress();
}
return "Unknown";
}
}
109 changes: 109 additions & 0 deletions app/src/main/java/com/m5dev/librecast/MainActivity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.m5dev.librecast;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

private TextView textServerStatus;
private TextView textConnectionStatus;

private final BroadcastReceiver statusReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (intent != null && ReceiverService.ACTION_STATUS_UPDATE.equals(intent.getAction())) {
updateUI(intent);
}
}
};

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

// Keep screen on for TV display and set full screen
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
hideSystemUI();

setContentView(R.layout.activity_main);

textServerStatus = findViewById(R.id.text_server_status);
textConnectionStatus = findViewById(R.id.text_connection_status);

startReceiverService();
}

@Override
protected void onStart() {
super.onStart();
IntentFilter filter = new IntentFilter(ReceiverService.ACTION_STATUS_UPDATE);
registerReceiver(statusReceiver, filter);

// Request current status
Intent queryIntent = new Intent(this, ReceiverService.class);
queryIntent.setAction(ReceiverService.ACTION_QUERY_STATUS);
startService(queryIntent);
}

@Override
protected void onStop() {
super.onStop();
try {
unregisterReceiver(statusReceiver);
} catch (Exception ignored) {
}
}

@Override
protected void onResume() {
super.onResume();
hideSystemUI();
}

private void startReceiverService() {
Intent serviceIntent = new Intent(this, ReceiverService.class);
serviceIntent.setAction(ReceiverService.ACTION_START_SERVER);
startService(serviceIntent);
}

private void updateUI(Intent intent) {
boolean isRunning = intent.getBooleanExtra(ReceiverService.EXTRA_IS_RUNNING, false);
String serverIp = intent.getStringExtra(ReceiverService.EXTRA_SERVER_IP);
int serverPort = intent.getIntExtra(ReceiverService.EXTRA_SERVER_PORT, LibreCastServer.DEFAULT_PORT);
boolean isClientConnected = intent.getBooleanExtra(ReceiverService.EXTRA_IS_CLIENT_CONNECTED, false);
String clientIp = intent.getStringExtra(ReceiverService.EXTRA_CLIENT_IP);

if (isRunning) {
if (serverIp == null || serverIp.isEmpty() || "0.0.0.0".equals(serverIp)) {
serverIp = NetworkUtils.getLocalIpAddress(this);
}
textServerStatus.setText(getString(R.string.server_ready_format, serverIp, serverPort));
} else {
textServerStatus.setText(getString(R.string.server_stopped));
}

if (isClientConnected) {
textConnectionStatus.setText(getString(R.string.client_connected_format, clientIp != null ? clientIp : ""));
} else {
textConnectionStatus.setText(getString(R.string.waiting_for_connection));
}
}

private void hideSystemUI() {
View decorView = getWindow().getDecorView();
decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
}
}
68 changes: 68 additions & 0 deletions app/src/main/java/com/m5dev/librecast/NetworkUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.m5dev.librecast;

import android.content.Context;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Enumeration;
import java.util.Locale;

public class NetworkUtils {

/**
* Helper utility to get the device's local Wi-Fi or local network IPv4 address.
* Falls back to NetworkInterface enumeration if WifiManager returns 0.0.0.0.
*/
public static String getLocalIpAddress(Context context) {
if (context != null) {
try {
WifiManager wifiManager = (WifiManager) context.getApplicationContext()
.getSystemService(Context.WIFI_SERVICE);
if (wifiManager != null && wifiManager.isWifiEnabled()) {
WifiInfo wifiInfo = wifiManager.getConnectionInfo();
if (wifiInfo != null) {
int ipAddress = wifiInfo.getIpAddress();
if (ipAddress != 0) {
return String.format(Locale.US, "%d.%d.%d.%d",
(ipAddress & 0xff),
(ipAddress >> 8 & 0xff),
(ipAddress >> 16 & 0xff),
(ipAddress >> 24 & 0xff));
}
}
}
} catch (Exception ignored) {
}
}

return getNetworkInterfaceIpAddress();
}

/**
* Resolves local IPv4 address by checking network interfaces (Ethernet / Wi-Fi fallback).
*/
public static String getNetworkInterfaceIpAddress() {
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces != null) {
while (interfaces.hasMoreElements()) {
NetworkInterface netInterface = interfaces.nextElement();
if (netInterface.isLoopback() || !netInterface.isUp()) {
continue;
}
Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress addr = addresses.nextElement();
if (!addr.isLoopbackAddress() && addr instanceof Inet4Address) {
return addr.getHostAddress();
}
}
}
}
} catch (Exception ignored) {
}
return "0.0.0.0";
}
}
Loading
Loading