Skip to content
Draft
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
## Moonlight-Tizen-NaCl
GameStream client for Samsung Smart TV's running Tizen OS (3.0 to 6.0)

## PLEASE DO NOT USE MY FORK, THIS IS MY TESTING FORK. Please use the installation below or the actual link to the original which will be untouched, and linked to the official/original build.

https://github.com/OneLiberty/moonlight-tizen-nacl


### Note
As a non-developer with limited coding knowledge, I do my best to maintain the repository and address issues. If you encounter problems, please report them in the issue section. While I can't guarantee a solution, I will certainly investigate.
This project is delivered as a POC, don't expect good performances and a fully working environement.
Expand Down
1 change: 1 addition & 0 deletions config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<tizen:metadata key="http://samsung.com/tv/metadata/multiscreen.support" value="true"/>
<tizen:metadata key="http://samsung.com/tv/metadata/multitasking.support" value="true"/>
<name>MoonlightNaCl</name>
<tizen:privilege name="http://developer.samsung.com/privilege/productinfo"/>
<tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
<tizen:privilege name="http://tizen.org/privilege/application.launch"/>
<tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
Expand Down
175 changes: 118 additions & 57 deletions gamepad.cpp
Original file line number Diff line number Diff line change
@@ -1,42 +1,59 @@
#include "moonlight.hpp"

#include "ppapi/c/ppb_gamepad.h"

#include <Limelight.h>

#include <sstream>

#define AXIS_DEAD_ZONE 0.1f

static const unsigned short k_StandardGamepadButtonMapping[] = {
A_FLAG, B_FLAG, X_FLAG, Y_FLAG,
LB_FLAG, RB_FLAG,
0, 0, // Triggers
0, 0,
BACK_FLAG, PLAY_FLAG,
LS_CLK_FLAG, RS_CLK_FLAG,
UP_FLAG, DOWN_FLAG, LEFT_FLAG, RIGHT_FLAG,
SPECIAL_FLAG
};

static const unsigned int k_StandardGamepadTriggerButtonIndexes[] = {
6, 7
static const unsigned short k_BTGamepadButtonMapping[] = {
A_FLAG, // btn0
B_FLAG, // btn1
0, // btn2 unused
X_FLAG, // btn3
Y_FLAG, // btn4
0, // btn5 unused
0, // btn6 right stick X (handled as axis)
0, // btn7 right stick Y (handled as axis)
BACK_FLAG, // btn8
PLAY_FLAG, // btn9
LS_CLK_FLAG, // btn10
RS_CLK_FLAG, // btn11
UP_FLAG, // btn12
DOWN_FLAG, // btn13
LEFT_FLAG, // btn14
RIGHT_FLAG, // btn15
SPECIAL_FLAG // btn16
};

static bool s_padSeen[4] = {false, false, false, false};
static bool s_isTizenBT[4] = {false, false, false, false};

static float ApplyDeadZone(float value) {
if (value > -AXIS_DEAD_ZONE && value < AXIS_DEAD_ZONE) {
return 0.0f;
}
return value;
}

static short GetActiveGamepadMask(PP_GamepadsSampleData& gamepadData) {
short controllerIndex = 0;
short activeGamepadMask = 0;

for (unsigned int p = 0; p < gamepadData.length; p++) {
PP_GamepadSampleData& padData = gamepadData.items[p];

printf("[NaCl, GetActiveGamepadMask] Gamepad %u: connected = %d, timestamp = %f\n", p, padData.connected, padData.timestamp);

if (!padData.connected) {
continue;
}

if (padData.timestamp == 0) {
continue;
}

activeGamepadMask |= (1 << controllerIndex);
controllerIndex++;
}
Expand All @@ -48,80 +65,124 @@ void MoonlightInstance::PollGamepads() {
PP_GamepadsSampleData gamepadData;
short controllerIndex = 0;
short activeGamepadMask;

m_GamepadApi->Sample(pp_instance(), &gamepadData);

m_GamepadApi->Sample(pp_instance(), &gamepadData);
activeGamepadMask = GetActiveGamepadMask(gamepadData);

printf("[NaCl] Active gamepad mask: %d\n", activeGamepadMask);

for (unsigned int p = 0; p < gamepadData.length; p++) {
PP_GamepadSampleData& padData = gamepadData.items[p];

if (!padData.connected) {
s_padSeen[p] = false;
s_isTizenBT[p] = false;
controllerIndex++;
continue;
}

if (padData.timestamp == m_LastPadTimestamps[p]) {

// Latch BT detection once on first sight of this pad,
// but wait until both axes have settled to non-zero values
if (!s_padSeen[p]) {
if (padData.axes_length >= 2) {
float ax0 = padData.axes[0];
float ax1 = padData.axes[1];
if (ax0 != 0.0f && ax1 != 0.0f) {
s_padSeen[p] = true;
s_isTizenBT[p] = (ax0 > 0.5f && ax0 < 1.5f &&
ax1 > 0.5f && ax1 < 1.5f);
}
}
}

if (padData.timestamp != 0 && padData.timestamp == m_LastPadTimestamps[p]) {
controllerIndex++;
continue;
}

m_LastPadTimestamps[p] = padData.timestamp;
printf("[NaCl] Gamepad %u: timestamp = %f\n", p, padData.timestamp);

bool isTizenBT = s_isTizenBT[p];

int buttonFlags = 0;
unsigned char leftTrigger = 0, rightTrigger = 0;
short leftStickX = 0, leftStickY = 0;
short rightStickX = 0, rightStickY = 0;

// Handle buttons and triggers
for (unsigned int i = 0; i < padData.buttons_length; i++) {
if (i >= sizeof(k_StandardGamepadButtonMapping) / sizeof(k_StandardGamepadButtonMapping[0])) {
// Ignore unmapped buttons
break;

const unsigned short* mapping = isTizenBT ?
k_BTGamepadButtonMapping : k_StandardGamepadButtonMapping;
size_t mappingSize = isTizenBT ?
sizeof(k_BTGamepadButtonMapping) / sizeof(k_BTGamepadButtonMapping[0]) :
sizeof(k_StandardGamepadButtonMapping) / sizeof(k_StandardGamepadButtonMapping[0]);

if (isTizenBT) {
// Buttons
for (unsigned int i = 0; i < padData.buttons_length && i < mappingSize; i++) {
if (mapping[i] && padData.buttons[i] > 0.5f) {
buttonFlags |= mapping[i];
}
}

// Handle triggers first
if (i == k_StandardGamepadTriggerButtonIndexes[0]) {
leftTrigger = padData.buttons[i] * 0xFF;

// Left stick: axes offset by 1.0 (range 0-2, center ~1.0)
if (padData.axes_length >= 2) {
float lx = padData.axes[0] - 1.0f;
float ly = padData.axes[1] - 1.0f;
leftStickX = (short)(ApplyDeadZone(lx) * 0x7FFF);
leftStickY = (short)(-ApplyDeadZone(ly) * 0x7FFF);
}
else if (i == k_StandardGamepadTriggerButtonIndexes[1]) {
rightTrigger = padData.buttons[i] * 0xFF;

// Right stick: packed into btn6 (X) and btn7 (Y)
// range 0-1, center ~0.5, remap to -1 to 1
if (padData.buttons_length > 7) {
float rx = (padData.buttons[6] - 0.5f) * 2.0f;
float ry = (padData.buttons[7] - 0.5f) * 2.0f;
rightStickX = (short)(ApplyDeadZone(rx) * 0x7FFF);
rightStickY = (short)(-ApplyDeadZone(ry) * 0x7FFF);
}
// Now normal buttons
else if (padData.buttons[i] > 0.5f) {
buttonFlags |= k_StandardGamepadButtonMapping[i];

// LB, RB, LT, RT not reported on Tizen 4.0 BT - left as zero

} else {
// Standard USB layout
for (unsigned int i = 0; i < padData.buttons_length && i < mappingSize; i++) {
if (i == 6) {
leftTrigger = (unsigned char)(padData.buttons[i] * 0xFF);
continue;
}
if (i == 7) {
rightTrigger = (unsigned char)(padData.buttons[i] * 0xFF);
continue;
}
if (mapping[i] && padData.buttons[i] > 0.5f) {
buttonFlags |= mapping[i];
}
}

if (padData.axes_length >= 2) {
leftStickX = (short)(ApplyDeadZone(padData.axes[0]) * 0x7FFF);
leftStickY = (short)(-ApplyDeadZone(padData.axes[1]) * 0x7FFF);
}
if (padData.axes_length >= 4) {
rightStickX = (short)(ApplyDeadZone(padData.axes[2]) * 0x7FFF);
rightStickY = (short)(-ApplyDeadZone(padData.axes[3]) * 0x7FFF);
}
}

// Get left stick values
if (padData.axes_length >= 2) {
leftStickX = padData.axes[0] * 0x7FFF;
leftStickY = -padData.axes[1] * 0x7FFF;
}

// Get right stick values
if (padData.axes_length >= 4) {
rightStickX = padData.axes[2] * 0x7FFF;
rightStickY = -padData.axes[3] * 0x7FFF;
}


LiSendMultiControllerEvent(controllerIndex, activeGamepadMask,
buttonFlags, leftTrigger, rightTrigger,
leftStickX, leftStickY, rightStickX, rightStickY);
buttonFlags, leftTrigger, rightTrigger,
leftStickX, leftStickY, rightStickX, rightStickY);

controllerIndex++;
}
}

void MoonlightInstance::ClControllerRumble(unsigned short controllerNumber, unsigned short lowFreqMotor, unsigned short highFreqMotor)
void MoonlightInstance::ClControllerRumble(unsigned short controllerNumber,
unsigned short lowFreqMotor, unsigned short highFreqMotor)
{
const float weakMagnitude = static_cast<float>(highFreqMotor) / static_cast<float>(UINT16_MAX);
const float strongMagnitude = static_cast<float>(lowFreqMotor) / static_cast<float>(UINT16_MAX);

std::ostringstream ss;
ss << controllerNumber << "," << weakMagnitude << "," << strongMagnitude;

pp::Var response(std::string("controllerRumble: ") + ss.str());
g_Instance->PostMessage(response);
}
}
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ <h5 id="loadingMessage"></h5>
<div id="naclSpinner" class="mdl-progress mdl-js-progress mdl-progress__indeterminate">
<h5 id="naclSpinnerMessage"></h5>
</div>
<div id="modelCodePlaceholder" style="position: fixed; bottom: 10px; right: 15px; color: #aaa; font-size: 14px; text-align: right;"></div>
</main>
</div>
<script defer src="static/js/jquery-2.2.0.min.js"></script>
Expand Down Expand Up @@ -192,4 +193,4 @@ <h3 class="mdl-dialog__title">Add Host Manually</h3>
<!-- this button exists to suppress the snackbar warning. we're really using a toast. -->
</div>
</body>
</html>
</html>
60 changes: 46 additions & 14 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ var myUniqueid = '0123456789ABCDEF'; // Use the same UID as other Moonlight clie
var api; // `api` should only be set if we're in a host-specific screen. on the initial screen it should always be null.
var isInGame = false; // flag indicating whether the game stream started

function loadProductInfos() {
const modelCodePlaceholder = document.getElementById("modelCodePlaceholder");
if (modelCodePlaceholder) {
const model = window.tizen.systeminfo.getCapability('http://tizen.org/system/model_name') || "Not Available";
const moonlightVersion = window.tizen.application.getAppInfo().version || "Not Available";
const tizenVersion = window.tizen.systeminfo.getCapability('http://tizen.org/feature/platform.version') || "Not Available";
modelCodePlaceholder.innerText = `TV Model: ${model} ; Moonlight: v${moonlightVersion} ; Tizen: v${tizenVersion}; This is a NaCl Build!`;
}
}


// Called by the common.js module.
function attachListeners() {
changeUiModeForNaClLoad();
Expand Down Expand Up @@ -55,6 +66,12 @@ function attachListeners() {
if (gamepadMapping[key]) {
gamepadMapping[key]();
}
});
// DEBUG: log all keydown events during gameplay to detect BT shoulder/trigger keys
window.addEventListener('keydown', function(e) {
if (isInGame) {
console.log('[index.js, keydown] isInGame keyCode=' + e.keyCode + ' key=' + e.key + ' keyIdentifier=' + e.keyIdentifier);
}
});
}

Expand Down Expand Up @@ -758,6 +775,7 @@ function startGame(host, appID) {
function playGameMode() {
console.log('%c[index.js, playGameMode]', 'color:green;', 'Entering play game mode');
isInGame = true;
Controller.stopWatching(); // Hand off controller to NaCl, stop JS polling

$("#main-navigation").hide();
$("#main-content").children().not("#listener, #loadingSpinner").hide();
Expand Down Expand Up @@ -821,6 +839,7 @@ function stopGameWithConfirmation() {

function stopGame(host, callbackFunction) {
isInGame = false;
Controller.startWatching(); // Return controller to JS for menu navigation

if (!host.paired) {
return;
Expand Down Expand Up @@ -1118,6 +1137,8 @@ function onWindowLoad() {
}
});

loadProductInfos();

console.log('Load stored remote audio prefs');
getData('remoteAudio', function (previousValue) {
if (previousValue.remoteAudio == null) {
Expand Down Expand Up @@ -1158,28 +1179,39 @@ function onWindowLoad() {
});

initSamsungKeys();

// DEBUG: dump all supported input device keys
try {
var supportedKeys = tizen.tvinputdevice.getSupportedKeys();
for (var i = 0; i < supportedKeys.length; i++) {
console.log('[index.js, supportedKeys] name=' + supportedKeys[i].name + ' code=' + supportedKeys[i].code);
}
} catch(e) {
console.log('[index.js, supportedKeys] failed:', e);
}
}


window.onload = onWindowLoad;

// Required on TizenTV, to get gamepad events.
window.addEventListener('gamepadconnected', function (event) {
var connectedGamepad = event.gamepad;
console.log('%c[index.js, gamepadconnected] gamepad connected: ', 'color: green;', connectedGamepad);

if (connectedGamepad.vibrationActuator) { // Check if the gamepad supports rumble
console.log('Gamepad supports vibration.');
connectedGamepad.vibrationActuator.playEffect('dual-rumble', {
duration: 1000,
strongMagnitude: 1.0,
weakMagnitude: 1.0});
} else {
console.log('Gamepad does not support vibration.');
// Wrapped in try/catch - Tizen 4.0 may crash on vibrationActuator access
try {
if (connectedGamepad.vibrationActuator && connectedGamepad.vibrationActuator.playEffect) {
console.log('Gamepad supports vibration.');
connectedGamepad.vibrationActuator.playEffect('dual-rumble', {
duration: 1000,
strongMagnitude: 1.0,
weakMagnitude: 1.0
});
} else {
console.log('Gamepad does not support vibration.');
}
} catch(e) {
console.log('Gamepad vibration not supported on this Tizen version:', e);
}
});

window.addEventListener('gamepaddisconnected', function (event) {
console.log('%c[index.js, gamepaddisconnected] gamepad disconnected: ' +
JSON.stringify(event.gamepad),
event.gamepad);
});
Loading