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

Initial TizenOS receiver commit

This commit is contained in:
Michael Hollister 2025-02-13 17:33:21 -06:00
parent 8c2eb78ef5
commit 2e5746645f
40 changed files with 9881 additions and 0 deletions

10
receivers/tizen/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
/.metadata/
dist
FCastReceiver/.buildResult
FCastReceiver/.settings
FCastReceiver/.sign
FCastReceiverService/.vs
FCastReceiverService/bin
FCastReceiverService/obj

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>FCastReceiver</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>json.validation.builder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.tizen.web.project.builder.WebBuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.tizen.web.jsa.JSAnalysisBuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>json.validation.nature</nature>
<nature>org.eclipse.wst.jsdt.core.jsNature</nature>
<nature>org.tizen.web.project.builder.WebNature</nature>
<nature>org.tizen.web.jsa.JSAnalysisNature</nature>
</natures>
</projectDescription>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tproject xmlns="http://www.tizen.org/tproject">
<platforms>
<platform>
<name>tv-samsung-8.0</name>
</platform>
</platforms>
<package>
<blacklist/>
</package>
</tproject>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets" id="http://futo.org/FCastReceiver" version="1.0.0" viewmodes="maximized">
<access origin="*" subdomains="true"></access>
<tizen:application id="qL5oFoTHoJ.FCastReceiver" package="qL5oFoTHoJ" required_version="5.0"/>
<content src="index.html"/>
<feature name="http://tizen.org/feature/screen.size.normal.1080.1920"/>
<icon src="icon.png"/>
<tizen:metadata key="http://tizen.org/metadata/app_ui_type/base_screen_resolution" value="extensive"/>
<name>FCastReceiver</name>
<tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
<tizen:privilege name="http://tizen.org/privilege/internet"/>
<tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
<tizen:profile name="tv-samsung"/>
<tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable" encryption="disable" install-location="auto" hwkey-event="enable"/>
</widget>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="$WEBAPIS/webapis/webapis.js"></script>
<meta http-equiv="refresh" content="0; url=dist/main_window/index.html" />
</head>
<body>
</body>
</html>

View file

@ -0,0 +1,265 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Serilog;
using Tizen.Applications;
using Tizen.Applications.Messages;
using System;
using Tizen.Network.Nsd;
namespace FCastReceiverService
{
internal class Program : ServiceApplication
{
private const string AppId = "qL5oFoTHoJ.FCastReceiver";
private const string AppPort = "ipcPort";
private static AppControl _appControl;
private static RemotePort _appPort;
public static MessagePort IpcPort { get; private set; }
private static DnssdService tcpDnssdService;
private static DnssdService wsDnssdService;
private static TcpListenerService tcpListenerService;
private static WebSocketListnerService webSocketListenerService;
public static bool websocketsSupported = false;
protected override void OnCreate()
{
base.OnCreate();
Serilog.Log.Information($"Starting: {Program.Current.ApplicationInfo.ApplicationId}");
Serilog.Log.Information($"Version: 1.0.0");
Serilog.Log.Information($"Manufacturer: {SystemInformation.Manufacturer}");
Serilog.Log.Information($"ModelName: {SystemInformation.ModelName}");
Serilog.Log.Information($"PlatformName: {SystemInformation.PlatformName}");
Serilog.Log.Information($"BuildDate: {SystemInformation.BuildDate}");
Serilog.Log.Information($"BuildId: {SystemInformation.BuildId}");
Serilog.Log.Information($"BuildRelease: {SystemInformation.BuildRelease}");
Serilog.Log.Information($"BuildString: {SystemInformation.BuildString}");
_appControl = new AppControl();
_appControl.ApplicationId = AppId;
_appPort = new RemotePort(AppId, AppPort, false);
_appPort.RemotePortStateChanged += RemotePortStateChanged;
IpcPort = new MessagePort(AppPort, false);
IpcPort.MessageReceived += IpcMainMessageCb;
IpcPort.Listen();
SendAppMessage("serviceStart");
// Note: Unable to find required shared library when running in emulator...
tcpDnssdService = new DnssdService("_fcast._tcp");
tcpDnssdService.Port = TcpListenerService.Port;
tcpDnssdService.Name = $"{SystemInformation.Manufacturer} {SystemInformation.ModelName}";
tcpDnssdService.RegisterService();
//wsDnssdService = new DnssdService("_fcast._ws");
wsDnssdService = new DnssdService("_fcast-ws._tcp");
wsDnssdService.Port = WebSocketListnerService.Port;
wsDnssdService.Name = $"{SystemInformation.Manufacturer} {SystemInformation.ModelName}";
wsDnssdService.RegisterService();
tcpListenerService = new TcpListenerService();
List<IListenerService> listeners = new List<IListenerService>() { tcpListenerService };
// Older Tizen models seem to throw exceptions when accessing standard .NET APIs for
// Querying network interface information or using HttpListeners...
// No API to get Tizen version, so have to go by OS image build date...
// Format: YYYYMMDD_HHMMSS
if (int.Parse(SystemInformation.BuildDate.Substring(0, 4)) > 2018)
{
websocketsSupported = true;
webSocketListenerService = new WebSocketListnerService();
listeners.Add(webSocketListenerService);
}
foreach (IListenerService l in listeners)
{
l.OnPlay += Program.OnPlay;
l.OnPause += (object sender, EventArgs e) => { SendAppMessage("pause"); };
l.OnResume += (object sender, EventArgs e) => { SendAppMessage("resume"); };
l.OnStop += (object sender, EventArgs e) => { SendAppMessage("stop"); };
l.OnSeek += (object sender, SeekMessage e) =>
{
SendAppMessage("seek", JsonSerializer.Serialize(e));
};
l.OnSetVolume += (object sender, SetVolumeMessage e) =>
{
SendAppMessage("setvolume", JsonSerializer.Serialize(e));
};
l.OnSetSpeed += (object sender, SetSpeedMessage e) =>
{
SendAppMessage("setspeed", JsonSerializer.Serialize(e));
};
l.OnPing += (object sender, Dictionary<string, string> e) => { SendAppMessage("ping", e); };
l.OnConnect += (object sender, Dictionary<string, string> e) => { SendAppMessage("connect", e); };
l.OnDisconnect += (object sender, Dictionary<string, string> e) => { SendAppMessage("disconnect", e); };
l.ListenAsync();
}
SendAppMessage("serviceStarted", new Dictionary<string, string>() {
{ "websocketsSupported", websocketsSupported.ToString() },
{ "buildDate", SystemInformation.BuildDate },
{ "buildId", SystemInformation.BuildId },
{ "buildRelease", SystemInformation.BuildRelease },
{ "buildString", SystemInformation.BuildString },
});
}
protected override void OnTerminate()
{
SendAppMessage("serviceExit");
tcpDnssdService.DeregisterService();
tcpDnssdService.Dispose();
wsDnssdService.DeregisterService();
wsDnssdService.Dispose();
base.OnTerminate();
}
private static void OnPlay(object sender, PlayMessage e)
{
if (!ApplicationManager.IsRunning(AppId))
{
Serilog.Log.Information("FCast application not running, launching application");
AppControl.SendLaunchRequest(_appControl);
ReattemptOnPlay(sender, e);
return;
}
else
{
ApplicationRunningContext appContext = new ApplicationRunningContext(AppId);
if (appContext.State == ApplicationRunningContext.AppState.Background)
{
Serilog.Log.Information("FCast application suspended, resuming");
appContext.Resume();
ReattemptOnPlay(sender, e);
return;
}
}
e = NetworkService.ProxyPlayIfRequired(e);
Serilog.Log.Information($"Sending play message: {e}");
SendAppMessage("play", JsonSerializer.Serialize(e));
}
private static void ReattemptOnPlay(object sender, PlayMessage e)
{
Task.Run(async () =>
{
int delay = 1000;
while (true)
{
// Drop play message after ~20s if app does not startup or resume to foreground
if (delay > 6000)
{
return;
}
if (ApplicationManager.IsRunning(AppId))
{
ApplicationRunningContext appContext = new ApplicationRunningContext(AppId);
if (appContext.State == ApplicationRunningContext.AppState.Foreground)
{
OnPlay(sender, e);
return;
}
}
Serilog.Log.Information($"Waiting {delay}ms for application to start");
await Task.Delay(delay);
delay += 1000;
}
});
}
public static void SendAppMessage(string message, Dictionary<string, string> data)
{
SendAppMessage(message, JsonSerializer.Serialize(data));
}
public static void SendAppMessage(string message, string data = "null")
{
if (_appPort.IsRunning())
{
Bundle bundle = new Bundle();
bundle.AddItem("message", message);
bundle.AddItem("data", data);
IpcPort.Send(bundle, AppId, AppPort);
}
else
{
Serilog.Log.Warning($"App is currently not running, cannot send message: {message} {data}");
}
}
private static void RemotePortStateChanged(object sender, RemotePortStateChangedEventArgs e)
{
switch (e.Status)
{
case State.Registered:
Serilog.Log.Information("Remote ipc port is registered");
break;
case State.Unregistered:
Serilog.Log.Information("Remote ipc port is unregistered");
break;
default:
break;
}
}
private static void IpcMainMessageCb(object sender, MessageReceivedEventArgs e)
{
Serilog.Log.Information($"Message received in main handler with {e.Message.Count} items");
e.Message.TryGetItem("command", out string command);
switch (command)
{
case "getSystemInfo":
SendAppMessage("getSystemInfo", new Dictionary<string, string>() {
{ "websocketsSupported", websocketsSupported.ToString() },
{ "buildDate", SystemInformation.BuildDate },
{ "buildId", SystemInformation.BuildId },
{ "buildRelease", SystemInformation.BuildRelease },
{ "buildString", SystemInformation.BuildString },
});
break;
default:
Serilog.Log.Information($"Unknown message with command {command}");
break;
}
}
public static void DebugAppMessage(string message, int icon = 0)
{
SendAppMessage("toast", new Dictionary<string, string>() { { "message", message }, { "icon", icon.ToString() } });
}
static void Main(string[] args)
{
try
{
Serilog.Log.Logger = new Serilog.LoggerConfiguration().WriteTo.Debug().CreateLogger();
var app = new Program();
app.Run(args);
}
catch (Exception e)
{
Serilog.Log.Error($"Network service: {e}");
DebugAppMessage($"Network service: {e}", 1);
}
}
}
}

View file

@ -0,0 +1,34 @@
<Project Sdk="Tizen.NET.Sdk/1.0.9">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugType>portable</DebugType>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>None</DebugType>
</PropertyGroup>
<ItemGroup>
<Folder Include="lib\" />
<Folder Include="res\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
<PackageReference Include="System.Text.Json" Version="5.0.2" />
<PackageReference Include="Tizen.NET" Version="5.0.0.14562">
<ExcludeAssets>Runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Tizen.NET.Sdk" Version="1.0.1" />
<PackageReference Include="Tizen.NET.TV" Version="5.5.0.4922">
<ExcludeAssets>Runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>FCastReceiverService</ActiveDebugProfile>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.35706.147
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FCastReceiverService", "FCastReceiverService.csproj", "{7F6139C0-A668-4494-8E15-60CD3196018F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7F6139C0-A668-4494-8E15-60CD3196018F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7F6139C0-A668-4494-8E15-60CD3196018F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7F6139C0-A668-4494-8E15-60CD3196018F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7F6139C0-A668-4494-8E15-60CD3196018F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {37E7DBDC-8F66-4DAE-B66E-D78AC619BC14}
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,312 @@
using System;
using System.Buffers.Binary;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public enum SessionState
{
Idle = 0,
WaitingForLength,
WaitingForData,
Disconnected
}
public class FCastSession : IDisposable
{
private const int LengthBytes = 4;
private const int MaximumPacketLength = 32000;
private byte[] _buffer = new byte[MaximumPacketLength];
private int _bytesRead;
private int _packetLength;
private Stream _stream;
private SemaphoreSlim _writerSemaphore = new SemaphoreSlim(1);
private SemaphoreSlim _readerSemaphore = new SemaphoreSlim(1);
private SessionState _state;
public event EventHandler<PlayMessage> OnPlay;
public event EventHandler OnPause;
public event EventHandler OnResume;
public event EventHandler OnStop;
public event EventHandler<SeekMessage> OnSeek;
public event EventHandler<SetVolumeMessage> OnSetVolume;
public event EventHandler<SetSpeedMessage> OnSetSpeed;
public event EventHandler<VersionMessage> OnVersion;
public event EventHandler OnPing;
public event EventHandler OnPong;
public event EventHandler OnData;
public event EventHandler OnTimeout;
public event EventHandler OnDispose;
public FCastSession(Stream stream)
{
_stream = stream;
_state = SessionState.Idle;
}
public async Task SendMessageAsync(Opcode opcode, CancellationToken cancellationToken)
{
await _writerSemaphore.WaitAsync();
try
{
int size = 1;
byte[] header = new byte[LengthBytes + 1];
Array.Copy(BitConverter.GetBytes(size), header, LengthBytes);
header[LengthBytes] = (byte)opcode;
Serilog.Log.Information($"Sent {header.Length} bytes with (opcode: {opcode}, header size: {header.Length}, no body).");
await _stream.WriteAsync(header, 0, header.Length, cancellationToken);
}
finally
{
_writerSemaphore.Release();
}
}
public async Task SendMessageAsync<T>(Opcode opcode, T message, CancellationToken cancellationToken) where T : class
{
await _writerSemaphore.WaitAsync();
try
{
string json = JsonSerializer.Serialize(message);
byte[] data = Encoding.UTF8.GetBytes(json);
int size = 1 + data.Length;
byte[] header = new byte[LengthBytes + 1];
Array.Copy(BitConverter.GetBytes(size), header, LengthBytes);
header[LengthBytes] = (byte)opcode;
byte[] packet = new byte[header.Length + data.Length];
header.CopyTo(packet, 0);
data.CopyTo(packet, header.Length);
Serilog.Log.Information($"Sent {packet.Length} bytes with (opcode: {opcode}, header size: {header.Length}, body size: {data.Length}, body: {json}).");
await _stream.WriteAsync(packet, 0, packet.Length, cancellationToken);
}
finally
{
_writerSemaphore.Release();
}
}
public async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
Serilog.Log.Information("Start receiving.");
_state = SessionState.WaitingForLength;
byte[] buffer = new byte[1024];
while (!cancellationToken.IsCancellationRequested)
{
await _readerSemaphore.WaitAsync();
int bytesRead;
try
{
//bytesRead = await _stream.ReadAsync(buffer, cancellationToken);
// Async read on IO streams do not support timeout functionality, but sync read on IO
// streams do support timeouts. However, the blocking read must be done on a separate
// background thread.
bytesRead = await Task.Run(() =>
{
try
{
return _stream.Read(buffer, 0, buffer.Length);
}
catch (IOException e)
{
throw e;
}
});
if (bytesRead == 0)
{
Serilog.Log.Information("Connection shutdown gracefully.");
Dispose();
break;
}
}
catch (IOException)
{
OnTimeout?.Invoke(this, EventArgs.Empty);
continue;
}
finally
{
_readerSemaphore.Release();
}
OnData?.Invoke(this, EventArgs.Empty);
await ProcessBytesAsync(buffer, bytesRead, cancellationToken);
}
_state = SessionState.Idle;
}
private async Task ProcessBytesAsync(byte[] receivedBytes, int length, CancellationToken cancellationToken)
{
Serilog.Log.Information($"{length} bytes received");
switch (_state)
{
case SessionState.WaitingForLength:
await HandleLengthBytesAsync(receivedBytes, 0, length, cancellationToken);
break;
case SessionState.WaitingForData:
await HandlePacketBytesAsync(receivedBytes, 0, length, cancellationToken);
break;
default:
Serilog.Log.Warning($"Data received is unhandled in current session state {_state}");
break;
}
}
private async Task HandleLengthBytesAsync(byte[] receivedBytes, int offset, int length, CancellationToken cancellationToken)
{
int bytesToRead = Math.Min(LengthBytes, length);
Buffer.BlockCopy(receivedBytes, offset, _buffer, _bytesRead, bytesToRead);
_bytesRead += bytesToRead;
Serilog.Log.Information($"handleLengthBytes: Read {bytesToRead} bytes from packet");
if (_bytesRead >= LengthBytes)
{
_state = SessionState.WaitingForData;
_packetLength = BinaryPrimitives.ReadInt32LittleEndian(_buffer);
_bytesRead = 0;
Serilog.Log.Information($"Packet length header received from: {_packetLength}");
if (_packetLength > MaximumPacketLength)
{
Serilog.Log.Error($"Maximum packet length is 32kB, killing stream: {_packetLength}");
Dispose();
_state = SessionState.Disconnected;
throw new InvalidOperationException($"Stream killed due to packet length ({_packetLength}) exceeding maximum 32kB packet size.");
}
if (length > bytesToRead)
{
await HandlePacketBytesAsync(receivedBytes, offset + bytesToRead, length - bytesToRead, cancellationToken);
}
}
}
private async Task HandlePacketBytesAsync(byte[] receivedBytes, int offset, int length, CancellationToken cancellationToken)
{
int bytesToRead = Math.Min(_packetLength, length);
Buffer.BlockCopy(receivedBytes, offset, _buffer, _bytesRead, bytesToRead);
_bytesRead += bytesToRead;
Serilog.Log.Information($"handlePacketBytes: Read {bytesToRead} bytes from packet");
if (_bytesRead >= _packetLength)
{
Serilog.Log.Information($"Packet finished receiving of {_packetLength} bytes.");
await HandleNextPacketAsync(cancellationToken);
_state = SessionState.WaitingForLength;
_packetLength = 0;
_bytesRead = 0;
if (length > bytesToRead)
{
await HandleLengthBytesAsync(receivedBytes, offset + bytesToRead, length - bytesToRead, cancellationToken);
}
}
}
private async Task HandleNextPacketAsync(CancellationToken cancellationToken)
{
Serilog.Log.Information($"Processing packet of {_bytesRead} bytes");
Opcode opcode = (Opcode)_buffer[0];
int packetLength = _packetLength;
string body = packetLength > 1 ? Encoding.UTF8.GetString(_buffer, 1, packetLength - 1) : null;
Serilog.Log.Information($"Received body: {body}");
await HandlePacketAsync(opcode, body, cancellationToken);
}
private async Task HandlePacketAsync(Opcode opcode, string body, CancellationToken cancellationToken)
{
Serilog.Log.Information($"Received message with opcode {opcode}.");
switch (opcode)
{
case Opcode.Play:
OnPlay?.Invoke(this, JsonSerializer.Deserialize<PlayMessage>(body));
break;
case Opcode.Pause:
OnPause?.Invoke(this, EventArgs.Empty);
break;
case Opcode.Resume:
OnResume?.Invoke(this, EventArgs.Empty);
break;
case Opcode.Stop:
OnStop?.Invoke(this, EventArgs.Empty);
break;
case Opcode.Seek:
OnSeek?.Invoke(this, JsonSerializer.Deserialize<SeekMessage>(body));
break;
case Opcode.SetVolume:
OnSetVolume?.Invoke(this, JsonSerializer.Deserialize<SetVolumeMessage>(body));
break;
case Opcode.SetSpeed:
OnSetSpeed?.Invoke(this, JsonSerializer.Deserialize<SetSpeedMessage>(body));
break;
case Opcode.PlaybackUpdate:
HandleMessage<PlaybackUpdateMessage>(body, "Received playback update");
break;
case Opcode.VolumeUpdate:
HandleMessage<VolumeUpdateMessage>(body, "Received volume update");
break;
case Opcode.PlaybackError:
HandleMessage<PlaybackErrorMessage>(body, "Received playback error");
break;
case Opcode.Version:
OnVersion?.Invoke(this, JsonSerializer.Deserialize<VersionMessage>(body));
break;
case Opcode.Ping:
await SendMessageAsync(Opcode.Pong, cancellationToken);
OnPing?.Invoke(this, EventArgs.Empty);
break;
case Opcode.Pong:
OnPong?.Invoke(this, EventArgs.Empty);
break;
default:
Serilog.Log.Warning($"Error handling packet with opcode '{opcode}' and body '{body}'");
break;
}
}
private void HandleMessage<T>(string body, string logMessage) where T : class
{
if (!string.IsNullOrEmpty(body))
{
T message = JsonSerializer.Deserialize<T>(body);
if (message != null)
{
Serilog.Log.Information($"{logMessage} {JsonSerializer.Serialize(message)}");
}
else
{
Serilog.Log.Information($"{logMessage} with malformed body.");
}
}
else
{
Serilog.Log.Information($"{logMessage} with no body.");
}
}
public void Dispose()
{
OnDispose?.Invoke(this, EventArgs.Empty);
_stream.Dispose();
}
}

View file

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace FCastReceiverService
{
public interface IListenerService
{
event EventHandler<PlayMessage> OnPlay;
event EventHandler OnPause;
event EventHandler OnResume;
event EventHandler OnStop;
event EventHandler<SeekMessage> OnSeek;
event EventHandler<SetVolumeMessage> OnSetVolume;
event EventHandler<SetSpeedMessage> OnSetSpeed;
event EventHandler<VersionMessage> OnVersion;
event EventHandler<Dictionary<string, string>> OnPing;
event EventHandler OnPong;
event EventHandler<Dictionary<string, string>> OnConnect;
event EventHandler<Dictionary<string, string>> OnDisconnect;
Task ListenAsync();
}
}

View file

@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace FCastReceiverService
{
public class NetworkService
{
private static HttpListener _proxyServer;
private static int _proxyServerPort;
private static string _proxyFileMimeType;
private static CancellationTokenSource _cancellationTokenSource;
private static readonly Dictionary<string, (string, Dictionary<string, string>)> _proxiedFiles = new Dictionary<string, (string, Dictionary<string, string>)>();
private static readonly string[] _streamingMediaTypes = {
"application/vnd.apple.mpegurl",
"application/x-mpegURL",
"application/dash+xml"
};
private static void SetupProxyServer()
{
Serilog.Log.Information("Proxy server starting");
_proxyServer = new HttpListener();
_proxyServerPort = GetAvailablePort();
_cancellationTokenSource = new CancellationTokenSource();
_proxyServer.Prefixes.Add($"http://127.0.0.1:{GetAvailablePort()}/");
_proxyServer.Start();
Serilog.Log.Information($"Proxy server running at http://127.0.0.1:{_proxyServerPort}/");
var serverTask = Task.Run(async () =>
{
DateTime lastRequestTime = DateTime.Now;
int activeConnections = 0;
HttpClient client = new HttpClient();
while (!_cancellationTokenSource.IsCancellationRequested)
{
if (activeConnections == 0 && (DateTime.Now - lastRequestTime).TotalSeconds > 300)
{
Serilog.Log.Information("No activity on server, closing...");
break;
}
if (_proxyServer.IsListening)
{
var contextTask = _proxyServer.GetContextAsync();
await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, _cancellationTokenSource.Token));
if (_cancellationTokenSource.IsCancellationRequested)
break;
var context = contextTask.Result;
Serilog.Log.Information("Request received.");
// Note: Incomplete implementation, cannot use on Tizen due to sanboxing
// blocking requests to localhost between different processes.
try
{
Interlocked.Increment(ref activeConnections);
lastRequestTime = DateTime.Now;
var request = context.Request;
var response = context.Response;
string requestUrl = request.Url.ToString();
Serilog.Log.Information($"Request URL: {request.Url}");
if (!_proxiedFiles.TryGetValue(requestUrl, out var proxyInfo))
{
response.StatusCode = 404;
response.Close();
continue;
}
// TODO Add header custom headers and omitting standard headers
//var response = context.Response;
response.ContentType = _proxyFileMimeType;
var requestStream = await client.GetStreamAsync(proxyInfo.Item1);
await requestStream.CopyToAsync(response.OutputStream);
//using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
// await fileStream.CopyToAsync(response.OutputStream);
response.OutputStream.Close();
}
catch (Exception ex)
{
Serilog.Log.Error($"Error handling request: {ex.Message}");
}
finally
{
Interlocked.Decrement(ref activeConnections);
}
}
else
{
await Task.Delay(5000);
}
}
client.Dispose();
_proxyServer.Stop();
}, _cancellationTokenSource.Token);
}
private static int GetAvailablePort()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Bind(new IPEndPoint(IPAddress.Any, 0));
return ((IPEndPoint)socket.LocalEndPoint).Port;
}
public static PlayMessage ProxyPlayIfRequired(PlayMessage message)
{
// Disabling file proxying on Tizen, since localhost requests between processes
// are blocked due to sandboxing...
//if (message.Headers != null && message.Url != null && !_streamingMediaTypes.Contains(message.Container.ToLower()))
//{
// _proxyFileMimeType = message.Container;
// message.Url = ProxyFile(message.Url, message.Headers);
//}
return message;
}
public static string ProxyFile(string url, Dictionary<string, string> headers)
{
if (_proxyServer is null)
{
SetupProxyServer();
}
Guid urlId = Guid.NewGuid();
string proxiedUrl = $"http://127.0.0.1:{_proxyServerPort}/{urlId.ToString()}";
Serilog.Log.Information($"Proxied url {proxiedUrl} {url} {headers}");
_proxiedFiles.Add(proxiedUrl, (url, headers));
return proxiedUrl;
}
public static List<IPAddress> GetAllIPAddresses()
{
//return Dns.GetHostAddresses(Dns.GetHostName())
// .Where(x => IsPrivate(x) && !IPAddress.IsLoopback(x) && x.AddressFamily == AddressFamily.InterNetwork)
// .ToList();
return NetworkInterface.GetAllNetworkInterfaces().SelectMany(v => v.GetIPProperties()
.UnicastAddresses
.Select(x => x.Address)
.Where(x => !IPAddress.IsLoopback(x) && x.AddressFamily == AddressFamily.InterNetwork))
.ToList();
}
// https://datatracker.ietf.org/doc/html/rfc1918
public static bool IsPrivate(IPAddress addr)
{
byte[] bytes = addr.GetAddressBytes();
switch (bytes[0])
{
case 10:
return true;
case 172:
return bytes[1] < 32 && bytes[1] >= 16;
case 192:
return bytes[1] == 168;
default:
return false;
}
}
}
}

View file

@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
public enum Opcode
{
None = 0,
Play,
Pause,
Resume,
Stop,
Seek,
PlaybackUpdate,
VolumeUpdate,
SetVolume,
PlaybackError,
SetSpeed,
Version,
Ping,
Pong
}
public class PlayMessage
{
[JsonPropertyName("container")]
public string Container { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
[JsonPropertyName("time")]
public double? Time { get; set; }
[JsonPropertyName("speed")]
public double? Speed { get; set; }
[JsonPropertyName("headers")]
public Dictionary<string, string> Headers { get; set; }
}
public class SeekMessage
{
[JsonPropertyName("time")]
public double Time { get; set; }
}
public class PlaybackUpdateMessage
{
[JsonPropertyName("generationTime")]
public double GenerationTime { get; set; }
[JsonPropertyName("time")]
public double Time { get; set; }
[JsonPropertyName("duration")]
public double Duration { get; set; }
[JsonPropertyName("speed")]
public double Speed { get; set; }
[JsonPropertyName("state")]
public int State { get; set; } // 0 = None, 1 = Playing, 2 = Paused
}
public class PlaybackErrorMessage
{
[JsonPropertyName("message")]
public string Message { get; set; }
}
public class VolumeUpdateMessage
{
[JsonPropertyName("generationTime")]
public double GenerationTime { get; set; }
[JsonPropertyName("volume")]
public double Volume { get; set; } // (0-1)
}
public class SetVolumeMessage
{
[JsonPropertyName("volume")]
public double Volume { get; set; }
}
public class SetSpeedMessage
{
[JsonPropertyName("speed")]
public double Speed { get; set; }
}
public class VersionMessage
{
[JsonPropertyName("version")]
public double Version { get; set; }
}

View file

@ -0,0 +1,129 @@

namespace FCastReceiverService
{
/// <summary>
/// https://www.tizen.org/system
/// </summary>
public static class SystemInformation
{
/// <summary>
/// The platform returns the build date. The build date is made when platform image is created
/// </summary>
public static string BuildDate { get; private set; }
/// <summary>
/// The platform returns a changelist number such as "tizen-mobile-RC2".
/// The changelist number is made when platform image is created.
/// </summary>
public static string BuildId { get; private set; }
/// <summary>
/// The platform returns the build version information such as "20160307.1".
/// The build version information is made when platform image is created.
/// </summary>
public static string BuildRelease { get; private set; }
/// <summary>
/// The platform returns the build information string.
/// The build information string is made when platform image is created.
/// </summary>
public static string BuildString { get; private set; }
/// <summary>
/// The platform returns the build time. The build time is made when platform image is created.
/// </summary>
public static string BuildTime { get; private set; }
/// <summary>
/// The platform returns the build type such as "user" or "eng".
/// The build type is made when platform image is created.
/// </summary>
public static string BuildType { get; private set; }
/// <summary>
/// The platform returns variant release information.
/// The variant release information is made when platform image is created.
/// </summary>
public static string BuildVariant { get; private set; }
/// <summary>
/// The platform returns the manufacturer name.
/// </summary>
public static string Manufacturer { get; private set; }
/// <summary>
/// The platform returns the device model name.
/// </summary>
public static string ModelName { get; private set; }
/// <summary>
/// The platform returns the Platform name.
/// </summary>
public static string PlatformName { get; private set; }
static SystemInformation()
{
string temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/build.date", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: BuildDate");
}
BuildDate = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/build.id", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: BuildId");
}
BuildId = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/build.release", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: BuildRelease");
}
BuildRelease = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/build.string", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: BuildString");
}
BuildString = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/build.time", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: BuildTime");
}
BuildTime = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/build.type", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: BuildType");
}
BuildType = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/build.variant", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: BuildVariant");
}
BuildVariant = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/manufacturer", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: Manufacturer");
}
Manufacturer = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/model_name", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: ModelName");
}
ModelName = temp;
if (Tizen.System.Information.TryGetValue("http://tizen.org/system/platform.name", out temp) == false)
{
Serilog.Log.Warning($"Error initializing SystemInformation field: PlatformName");
}
PlatformName = temp;
}
}
}

View file

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Tizen.Applications.Messages;
namespace FCastReceiverService
{
public class TcpListenerService : IListenerService, IDisposable
{
public const int Port = 46899;
private const int Timeout = 2500;
private TcpListener _listener;
private List<FCastSession> _sessions;
private CancellationTokenSource _cancellationTokenSource;
public event EventHandler<PlayMessage> OnPlay;
public event EventHandler OnPause;
public event EventHandler OnResume;
public event EventHandler OnStop;
public event EventHandler<SeekMessage> OnSeek;
public event EventHandler<SetVolumeMessage> OnSetVolume;
public event EventHandler<SetSpeedMessage> OnSetSpeed;
public event EventHandler<VersionMessage> OnVersion;
public event EventHandler<Dictionary<string, string>> OnPing;
public event EventHandler OnPong;
public event EventHandler<Dictionary<string, string>> OnConnect;
public event EventHandler<Dictionary<string, string>> OnDisconnect;
public TcpListenerService()
{
_sessions = new List<FCastSession>();
_cancellationTokenSource = new CancellationTokenSource();
_listener = new TcpListener(Port);
_listener.Start();
}
public async Task ListenAsync()
{
Serilog.Log.Information("Listening for TCP connections...");
while (!_cancellationTokenSource.IsCancellationRequested)
{
//TcpClient client = await _listener.AcceptTcpClientAsync(_cancellationTokenSource.Token);
TcpClient client = await _listener.AcceptTcpClientAsync();
Serilog.Log.Information($"New TCP connection from {client.Client.RemoteEndPoint}");
client.ReceiveTimeout = Timeout;
NetworkStream stream = client.GetStream();
FCastSession session = new FCastSession(stream);
Guid connectionId = Guid.NewGuid();
int heartbeatRetries = 0;
session.OnPlay += OnPlay;
session.OnPause += OnPause;
session.OnResume += OnResume;
session.OnStop += OnStop;
session.OnSeek += OnSeek;
session.OnSetVolume += OnSetVolume;
session.OnSetSpeed += OnSetSpeed;
session.OnVersion += OnVersion;
session.OnPing += (object sender, EventArgs e) =>
{
OnPing?.Invoke(this, new Dictionary<string, string>() { { "id", connectionId.ToString() } });
};
session.OnPong += OnPong;
_sessions.Add(session);
EventHandler<MessageReceivedEventArgs> ipcMessageCb = (object sender, MessageReceivedEventArgs e) =>
{
Serilog.Log.Information($"Message received in tcp handler with {e.Message.Count} items");
e.Message.TryGetItem("opcode", out string opcode);
Enum.TryParse(opcode, out Opcode code);
e.Message.TryGetItem("data", out string data);
switch (code)
{
case Opcode.PlaybackError:
_ = session.SendMessageAsync(code, JsonSerializer.Deserialize<PlaybackErrorMessage>(data), _cancellationTokenSource.Token);
break;
case Opcode.PlaybackUpdate:
_ = session.SendMessageAsync(code, JsonSerializer.Deserialize<PlaybackUpdateMessage>(data), _cancellationTokenSource.Token);
break;
case Opcode.VolumeUpdate:
_ = session.SendMessageAsync(code, JsonSerializer.Deserialize<VolumeUpdateMessage>(data), _cancellationTokenSource.Token);
break;
default:
Serilog.Log.Information($"Unknown message with opcode {code} and data {data}");
break;
}
};
Program.IpcPort.MessageReceived += ipcMessageCb;
session.OnTimeout += (object sender, EventArgs e) =>
{
try
{
if (heartbeatRetries > 3)
{
Serilog.Log.Warning($"Could not ping device {client.Client.RemoteEndPoint}. Disconnecting...");
session.Dispose();
}
heartbeatRetries += 1;
_ = session.SendMessageAsync(Opcode.Ping, _cancellationTokenSource.Token);
}
catch
{
Serilog.Log.Warning($"Error while pinging sender device {client.Client.RemoteEndPoint}.");
session.Dispose();
}
};
session.OnData += (object sender, EventArgs e) => { heartbeatRetries = 0; };
session.OnDispose += (object sender, EventArgs e) =>
{
_sessions.Remove(session);
Program.IpcPort.MessageReceived -= ipcMessageCb;
OnDisconnect?.Invoke(this, new Dictionary<string, string>() {
{ "id", connectionId.ToString() },
{ "type", "tcp" },
{ "data", JsonSerializer.Serialize(new Dictionary<string, string>() { { "address", client.Client.RemoteEndPoint.ToString() } }) }
});
};
OnConnect?.Invoke(this, new Dictionary<string, string>() {
{ "id", connectionId.ToString() },
{ "type", "tcp" },
{ "data", JsonSerializer.Serialize(new Dictionary<string, string>() { { "address", client.Client.RemoteEndPoint.ToString() } }) }
});
Serilog.Log.Information("Sending version");
_ = session.SendMessageAsync(Opcode.Version, new VersionMessage() { Version = 2, }, _cancellationTokenSource.Token);
_ = SessionListenAsync(client, session);
}
}
private async Task SessionListenAsync(TcpClient client, FCastSession session)
{
try
{
await session.ReceiveLoopAsync(_cancellationTokenSource.Token);
}
catch (SocketException e)
{
Serilog.Log.Error($"Socket error from {client.Client.RemoteEndPoint}: {e}");
}
finally
{
session.Dispose();
client.Dispose();
}
}
public void Dispose()
{
_listener.Stop();
}
}
}

View file

@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Tizen.Applications.Messages;
namespace FCastReceiverService
{
public class WebSocketListnerService : IListenerService, IDisposable
{
public const int Port = 46898;
private HttpListener _listener;
private List<FCastSession> _sessions;
private CancellationTokenSource _cancellationTokenSource;
public event EventHandler<PlayMessage> OnPlay;
public event EventHandler OnPause;
public event EventHandler OnResume;
public event EventHandler OnStop;
public event EventHandler<SeekMessage> OnSeek;
public event EventHandler<SetVolumeMessage> OnSetVolume;
public event EventHandler<SetSpeedMessage> OnSetSpeed;
public event EventHandler<VersionMessage> OnVersion;
public event EventHandler<Dictionary<string, string>> OnPing;
public event EventHandler OnPong;
public event EventHandler<Dictionary<string, string>> OnConnect;
public event EventHandler<Dictionary<string, string>> OnDisconnect;
public WebSocketListnerService()
{
_sessions = new List<FCastSession>();
_cancellationTokenSource = new CancellationTokenSource();
_listener = new HttpListener();
foreach (IPAddress address in NetworkService.GetAllIPAddresses())
{
Serilog.Log.Information($"Adding WS listener address: {address}");
_listener.Prefixes.Add($"http://{address}:{Port}/");
}
_listener.Start();
}
public async Task ListenAsync()
{
Serilog.Log.Information("Listening for WS connections...");
while (!_cancellationTokenSource.IsCancellationRequested)
{
HttpListenerContext context = await _listener.GetContextAsync();
if (!context.Request.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
context.Response.Close();
continue;
}
HttpListenerWebSocketContext webSocketContext = await context.AcceptWebSocketAsync(null);
Serilog.Log.Information($"New WS connection from {webSocketContext.Origin}");
FCastSession session = new FCastSession(new WebSocketStream(webSocketContext.WebSocket));
Guid connectionId = Guid.NewGuid();
session.OnPlay += OnPlay;
session.OnPause += OnPause;
session.OnResume += OnResume;
session.OnStop += OnStop;
session.OnSeek += OnSeek;
session.OnSetVolume += OnSetVolume;
session.OnSetSpeed += OnSetSpeed;
session.OnVersion += OnVersion;
session.OnPing += (object sender, EventArgs e) =>
{
OnPing?.Invoke(this, new Dictionary<string, string>() { { "id", connectionId.ToString() } });
};
session.OnPong += OnPong;
_sessions.Add(session);
EventHandler<MessageReceivedEventArgs> ipcMessageCb = (object sender, MessageReceivedEventArgs e) =>
{
Serilog.Log.Information($"Message received in websockets handler with {e.Message.Count} items");
e.Message.TryGetItem("opcode", out string opcode);
Enum.TryParse(opcode, out Opcode code);
e.Message.TryGetItem("data", out string data);
switch (code)
{
case Opcode.PlaybackError:
_ = session.SendMessageAsync(code, JsonSerializer.Deserialize<PlaybackErrorMessage>(data), _cancellationTokenSource.Token);
break;
case Opcode.PlaybackUpdate:
_ = session.SendMessageAsync(code, JsonSerializer.Deserialize<PlaybackUpdateMessage>(data), _cancellationTokenSource.Token);
break;
case Opcode.VolumeUpdate:
_ = session.SendMessageAsync(code, JsonSerializer.Deserialize<VolumeUpdateMessage>(data), _cancellationTokenSource.Token);
break;
default:
Serilog.Log.Information($"Unknown message with opcode {code} and data {data}");
break;
}
};
Program.IpcPort.MessageReceived += ipcMessageCb;
session.OnDispose += (object sender, EventArgs e) =>
{
_sessions.Remove(session);
Program.IpcPort.MessageReceived -= ipcMessageCb;
OnDisconnect?.Invoke(this, new Dictionary<string, string>() {
{ "id", connectionId.ToString() },
{ "type", "ws" },
{ "data", JsonSerializer.Serialize(new Dictionary<string, string>() { { "url", webSocketContext.Origin } }) }
});
};
OnConnect?.Invoke(this, new Dictionary<string, string>() {
{ "id", connectionId.ToString() },
{ "type", "ws" },
{ "data", JsonSerializer.Serialize(new Dictionary<string, string>() { { "url", webSocketContext.Origin } }) }
});
Serilog.Log.Information("Sending version");
_ = session.SendMessageAsync(Opcode.Version, new VersionMessage() { Version = 2, }, _cancellationTokenSource.Token);
_ = SessionListenAsync(webSocketContext, session);
}
}
private async Task SessionListenAsync(HttpListenerWebSocketContext context, FCastSession session)
{
try
{
await session.ReceiveLoopAsync(_cancellationTokenSource.Token);
}
catch (SocketException e)
{
Serilog.Log.Error($"Socket error from {context.Origin}: {e}");
}
finally
{
session.Dispose();
}
}
public void Dispose()
{
_listener.Stop();
}
}
}

View file

@ -0,0 +1,49 @@
using System.IO;
using System;
using System.Net.WebSockets;
using System.Threading;
public class WebSocketStream : Stream
{
private readonly WebSocket _webSocket;
public WebSocketStream(WebSocket webSocket)
{
_webSocket = webSocket;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count)
{
var segment = new ArraySegment<byte>(buffer, offset, count);
var result = _webSocket.ReceiveAsync(segment, CancellationToken.None).Result;
return result.Count;
}
public override void Write(byte[] buffer, int offset, int count)
{
var segment = new ArraySegment<byte>(buffer, offset, count);
_webSocket.SendAsync(segment, WebSocketMessageType.Binary, true, CancellationToken.None).Wait();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.futo.FCastReceiverService" version="1.0.0" api-version="5" xmlns="http://tizen.org/ns/packages">
<author href="https://futo.org">FUTO</author>
<profile name="tv" />
<service-application appid="com.futo.FCastReceiverService" exec="FCastReceiverService.dll" multiple="false" nodisplay="true" taskmanage="false" type="dotnet" auto-restart="true" on-boot="true">
<label>FCastReceiverService</label>
<icon>icon.png</icon>
<background-category value="background-network" />
<splash-screens />
</service-application>
<shortcut-list />
<privileges>
<privilege>http://tizen.org/privilege/network.get</privilege>
<privilege>http://tizen.org/privilege/internet</privilege>
<privilege>http://tizen.org/privilege/appmanager.launch</privilege>
</privileges>
<provides-appdefined-privileges />
<feature name="http://tizen.org/feature/network.service_discovery.dnssd">true</feature>
</manifest>

33
receivers/tizen/README.md Normal file
View file

@ -0,0 +1,33 @@
# FCast WebOS Receiver
The FCast WebOS Receiver is split into two separate projects `fcast-receiver` for frontend UI and `fcast-receiver-service` for the background network service. The WebOS receiver is supported running on TV devices from WebOS TV 5.0 and later.
The TV receiver player is a simplified player compared to the Electron receiver due to functionality being redundant when using a TV remote control or due to platform limitations (https://gitlab.futo.org/videostreaming/fcast/-/issues/21).
# How to build
From `receivers/webos` directory:
## Prerequisites
```sh
npm install -g @webos-tools/cli
cd fcast-receiver
npm install
cd ../fcast-receiver-service
npm install
cd ../
```
## Build
```sh
cd fcast-receiver
npm run build
cd ../fcast-receiver-service
npm run build
cd ../
```
## Packaging
```sh
ares-package fcast-receiver/dist/ fcast-receiver-service/dist/ --no-minify
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,6 @@
cd FCastReceiver
cmd /C tizen build-web -- .
cd .buildResult
cmd /C tizen package -t wgt -s default -- .
cmd /C tizen package -t wgt -s default -r ..\..\FCastReceiverService\bin\Release\netcoreapp2.1\com.futo.FCastReceiverService-1.0.0.tpk -- FCastReceiver.wgt
cd ../../

8
receivers/tizen/build.sh Normal file
View file

@ -0,0 +1,8 @@
#!/bin/bash
cd FCastReceiver
tizen build-web -- .
cd .buildResult
tizen package -t wgt -s default -- .
tizen package -t wgt -s default -r ../../FCastReceiverService/bin/Release/netcoreapp2.1/com.futo.FCastReceiverService-1.0.0.tpk -- FCastReceiver.wgt
cd ../../

13
receivers/tizen/debug.bat Normal file
View file

@ -0,0 +1,13 @@
@REM cmd /C tizen install -n FCastReceiver/.buildResult/FCastReceiver.wgt -t T-samsung-9.0-x86
@REM cmd /C C:\tizen-studio\tools\sdb.exe -s emulator-26101 shell 0 debug qL5oFoTHoJ.FCastReceiver
cmd /C tizen install -n FCastReceiver.wgt -t UN43DU7200FXZA -- FCastReceiver/.buildResult
cmd /C C:\tizen-studio\tools\sdb.exe -s 192.168.0.218:26101 shell 0 debug qL5oFoTHoJ.FCastReceiver
@REM cmd /C tizen install -n FCastReceiver.wgt -t QN55Q89RAFXKR -- FCastReceiver/.buildResult
@REM cmd /C C:\tizen-studio\tools\sdb.exe -s 127.0.0.1:52513 shell 0 debug qL5oFoTHoJ.FCastReceiver
@REM C:\tizen-studio\tools\sdb.exe forward tcp:34445 tcp:34445
@REM must forward port after setting in chrome inspector?
@REM https://forum.developer.samsung.com/t/tizen-studio-build-for-web-app-takes-30-mins/11025/7

5
receivers/tizen/debug.sh Normal file
View file

@ -0,0 +1,5 @@
#!/bin/bash
tizen install -n FCastReceiver/.buildResult/FCastReceiver.wgt -t T-samsung-5.0-x86
~/tizen-studio/tools/sdb -s emulator-26101 shell 0 debug qL5oFoTHoJ.FCastReceiver
# ~/tizen-studio/tools/sdb forward tcp:34445 tcp:34445

View file

@ -0,0 +1,11 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{files: ["**/*.{js,mjs,cjs,ts}"]},
{languageOptions: { globals: globals.node }},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
];

View file

@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/test/**/*.test.ts'],
modulePathIgnorePatterns: ["<rootDir>/packaging/fcast/fcast-receiver-linux-x64/resources/app/package.json"],
};

7211
receivers/tizen/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,48 @@
{
"name": "com.futo.fcast.receiver",
"version": "1.0.0",
"description": "An application implementing a FCast receiver.",
"author": "FUTO",
"license": "MIT",
"scripts": {
"build": "rm -rf dist/ && rm -rf FCastReceiver/dist/ && webpack --config ./webpack.config.js && cp -r dist/ FCastReceiver/dist/",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@eslint/js": "^9.10.0",
"@types/jest": "^29.5.11",
"@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10",
"@types/qrcode": "^1.5.5",
"@types/tizen-common-web": "^2.0.6",
"@types/tizen-tv-webapis": "^2.0.6",
"@types/webostvjs": "^1.2.6",
"@types/workerpool": "^6.1.1",
"@types/ws": "^8.5.10",
"copy-webpack-plugin": "^12.0.2",
"eslint": "^9.10.0",
"globals": "^15.9.0",
"jest": "^29.7.0",
"mdns-js": "github:mdns-js/node-mdns-js",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.2",
"typescript": "^5.5.4",
"typescript-eslint": "^8.4.0",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
},
"dependencies": {
"bufferutil": "^4.0.8",
"dashjs": "^4.7.4",
"hls.js": "^1.5.15",
"http": "^0.0.1-security",
"https": "^1.0.0",
"log4js": "^6.9.1",
"qrcode": "^1.5.3",
"tizen-common-web": "^2.0.1",
"tizen-tv-webapis": "^2.0.0",
"url": "^0.11.3",
"uuid": "^9.0.1",
"ws": "^8.14.2"
}
}

View file

@ -0,0 +1,121 @@
import { preloadData } from 'common/main/Preload';
import { toast, ToastIcon } from 'common/components/Toast';
import * as tizen from 'tizen-common-web';
import { network } from 'tizen-tv-webapis';
enum RemoteKeyCode {
Stop = 413,
Rewind = 412,
Play = 415,
Pause = 19,
FastForward = 417,
Back = 10009,
MediaPlayPause = 10252,
}
const serviceId = 'qL5oFoTHoJ.FCastReceiverService.dll';
// const serviceId = 'com.futo.FCastReceiverService';
tizen.tvinputdevice.registerKeyBatch(['MediaRewind',
'MediaFastForward', 'MediaPlay', 'MediaPause', 'MediaStop'
]);
const manufacturer = tizen.systeminfo.getCapability("http://tizen.org/system/manufacturer");
const modelName = tizen.systeminfo.getCapability("http://tizen.org/system/model_name");
// network.getTVName() does not return a user-friendly name usually...
// preloadData.deviceInfo = { name: network.getTVName(), addresses: [network.getIp()] };
preloadData.deviceInfo = { name: `${manufacturer} ${modelName}`, addresses: [network.getIp()] };
preloadData.onDeviceInfoCb();
let servicePort;
const ipcPort = tizen.messageport.requestLocalMessagePort('ipcPort');
const ipcListener = ipcPort.addMessagePortListener((data) => {
const messageIndex = data.findIndex((i) => { return i.key === 'message' });
const dataIndex = data.findIndex((i) => { return i.key === 'data' });
const message = JSON.parse(data[dataIndex].value as string);
console.log('Received data:', JSON.stringify(data));
// console.log('Received message:', JSON.stringify(message));
switch (data[messageIndex].value) {
case 'serviceStart':
servicePort = tizen.messageport.requestRemoteMessagePort(serviceId, 'ipcPort');
break;
case 'serviceStarted':
case 'getSystemInfo':
console.log('System information');
console.log(`BuildDate: ${message.buildDate}`);
console.log(`BuildId: ${message.buildId}`);
console.log(`BuildRelease: ${message.buildRelease}`);
console.log(`BuildString: ${message.buildString}`);
console.log(`WebsocketsSupported: ${message.websocketsSupported}`);
if (!JSON.parse(message.websocketsSupported.toLowerCase())) {
const ports = document.getElementById('ip-ports');
ports.innerHTML = "Port<br>46899 (TCP)";
}
break;
case 'toast': {
toast(message.message, message.icon, message.duration);
break;
}
case 'connect':
preloadData.onConnectCb(null, message);
break;
case 'disconnect':
preloadData.onDisconnectCb(null, message);
break;
case 'ping':
preloadData.onPingCb(null, message);
break;
case 'play':
sessionStorage.setItem('playData', JSON.stringify(message));
window.open('../player/index.html', '_self');
break;
default:
console.warn(`Unknown ipc message type: ${data[messageIndex].value}, value: ${data[dataIndex].value}`);
break;
}
});
tizen.application.getAppsContext((contexts: tizen.ApplicationContext[]) => {
try {
servicePort = tizen.messageport.requestRemoteMessagePort(serviceId, 'ipcPort');
servicePort.sendMessage([{ key: 'command', value: "getSystemInfo" }]);
}
catch (error) {
console.warn(`Main: preload error setting up service port, will attempt again upon service start ${JSON.stringify(error)}`);
}
if (!contexts.find(ctx => ctx.appId === serviceId)) {
tizen.application.launch(serviceId, () => {
console.log('Main: preload launched network service');
}, (error: tizen.WebAPIError) => {
console.error(`Main: preload error launching network service ${JSON.stringify(error)}`);
toast(`Main: error launching network service ${JSON.stringify(error)}`, ToastIcon.ERROR);
});
}
}, (error: tizen.WebAPIError) => {
console.error(`Main: preload error querying running applications ${JSON.stringify(error)}`);
toast(`Main: error querying running applications ${JSON.stringify(error)}`, ToastIcon.ERROR);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
document.addEventListener('keydown', (event: any) => {
// console.log("KeyDown", event);
switch (event.keyCode) {
case RemoteKeyCode.Back:
tizen.application.getCurrentApplication().exit();
break;
default:
break;
}
});

View file

@ -0,0 +1,27 @@
import 'common/main/Renderer';
const backgroundVideo = document.getElementById('video-player');
const loadingScreen = document.getElementById('loading-screen');
// WebOS 6.0 requires global scope for access during callback invocation
// eslint-disable-next-line no-var
var backgroundVideoLoaded: boolean;
// eslint-disable-next-line no-var
var qrCodeRendered: boolean;
backgroundVideo.onplaying = () => {
backgroundVideoLoaded = true;
if (backgroundVideoLoaded && qrCodeRendered) {
loadingScreen.style.display = 'none';
backgroundVideo.onplaying = null;
}
};
export function onQRCodeRendered() {
qrCodeRendered = true;
if (backgroundVideoLoaded && qrCodeRendered) {
loadingScreen.style.display = 'none';
}
}

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<title>FCast Receiver</title>
<meta charset="UTF-8">
<script type="text/javascript" src="$WEBAPIS/webapis/webapis.js"></script>
<link rel="stylesheet" href="../assets/fonts/outfit.css" />
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="loading-screen">
<div id="loading-text" class="non-selectable">Loading FCast</div>
<div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
<div id="main-container">
<video id="video-player" class="video" autoplay loop>
<source src="../assets/video/background.mp4" type="video/mp4">
</video>
<div id="ui-container">
<div id="overlay">
<div id="main-view" >
<div id="title-container">
<div id="title-icon"></div>
<div id="title-text" class="non-selectable">FCast</div>
</div>
<div id="connection-status">
<div id="connection-status-text" class="non-selectable">Waiting for a connection</div>
<div id="connection-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="connection-check"><div id="connection-check-mark"></div></div>
</div>
</div>
<div id="detail-view" class="card">
<div class="non-selectable card-title">Manual connection information</div>
<div class="card-title-separator"></div>
<div>
<div id="ips">IPs</div><br />
<div id="ip-ports">Port<br>46899 (TCP), 46898 (WS)</div>
</div>
<div id="automatic-discovery" class="non-selectable">Automatic discovery is available via mDNS</div>
<canvas id="qr-code"></canvas>
<div id="scan-to-connect" class="non-selectable">Scan with a FCast sender app.</div>
<div id="app-download" class="non-selectable app-download">Need a sender app?<br>Download Grayjay at <u class="app-download">https://grayjay.app</u></div>
</div>
</div>
<div id="toast-notification">
<div id="toast-icon"></div>
<div id="toast-text"></div>
</div>
<div id="window-can-be-closed" class="non-selectable">App will continue to listen for connections when suspended in the background</div>
</div>
</div>
<script src="./preload.js"></script>
<script src="./renderer.js"></script>
</body>
</html>

View file

@ -0,0 +1,96 @@
.card-title {
font-family: InterBold;
}
#overlay {
font-family: InterRegular;
font-size: 28px;
/* gap not supported in WebOS 6.0 */
gap: unset;
}
#main-view {
padding: 25px;
/* gap not supported in WebOS 6.0 */
gap: unset;
margin-right: 15vw;
}
#title-text {
font-family: OutfitExtraBold;
font-size: 140px;
}
#title-icon {
width: 124px;
height: 124px;
}
#manual-connection-info {
font-family: InterBold;
}
#scan-to-connect {
font-family: InterBold;
}
.app-download {
font-weight: bold;
font-family: InterBold;
}
#window-can-be-closed {
font-family: InterRegular;
font-size: 24px;
}
.lds-ring {
width: 120px;
height: 120px;
}
.lds-ring div {
width: 104px;
height: 104px;
}
#loading-screen {
height: 100%;
width: 100%;
object-fit: cover;
background-color: black;
display: flex;
justify-content: center;
align-items: center;
}
#loading-text {
font-size: 100px;
font-weight: 800;
text-align: center;
color: white;
padding-right: 20px;
}
#connection-check {
width: 104px;
height: 104px;
}
#toast-notification {
gap: unset;
top: -250px;
}
#toast-icon {
width: 88px;
height: 88px;
margin-right: 20px;
}
#toast-text {
font-family: InterRegular;
font-size: 28px;
}

View file

@ -0,0 +1,100 @@
import { preloadData } from 'common/player/Preload';
import { Opcode, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
import { toast, ToastIcon } from 'common/components/Toast';
import * as tizen from 'tizen-common-web';
const serviceId = 'qL5oFoTHoJ.FCastReceiverService.dll';
// const serviceId = 'com.futo.FCastReceiverService';
const servicePort = tizen.messageport.requestRemoteMessagePort(serviceId, 'ipcPort');
preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => {
servicePort.sendMessage([
{ key: 'opcode', value: Opcode.PlaybackError.toString() },
{ key: 'data', value: JSON.stringify(error) }
]);
};
preloadData.sendPlaybackUpdateCb = (update: PlaybackUpdateMessage) => {
servicePort.sendMessage([
{ key: 'opcode', value: Opcode.PlaybackUpdate.toString() },
{ key: 'data', value: JSON.stringify(update) }
]);
};
preloadData.sendVolumeUpdateCb = (update: VolumeUpdateMessage) => {
servicePort.sendMessage([
{ key: 'opcode', value: Opcode.VolumeUpdate.toString() },
{ key: 'data', value: JSON.stringify(update) }
]);
};
let playerWindowOpen = false;
window.tizenOSAPI = {
pendingPlay: JSON.parse(sessionStorage.getItem('playData'))
};
const ipcPort = tizen.messageport.requestLocalMessagePort('ipcPort');
const ipcListener = ipcPort.addMessagePortListener((data) => {
const messageIndex = data.findIndex((i) => { return i.key === 'message' });
const dataIndex = data.findIndex((i) => { return i.key === 'data' });
const message = JSON.parse(data[dataIndex].value as string);
console.log('Received data:', JSON.stringify(data));
// console.log('Received message:', JSON.stringify(message));
switch (data[messageIndex].value) {
// case 'serviceStart':
// toast("FCast network service started");
// break;
case 'toast': {
toast(message.message, message.icon, message.duration);
break;
}
case 'ping':
break;
case 'play':
if (message !== null) {
if (!playerWindowOpen) {
playerWindowOpen = true;
}
if (preloadData.onPlayCb === undefined) {
window.tizenOSAPI.pendingPlay = message;
}
else {
preloadData.onPlayCb(null, message);
}
}
break;
case 'pause':
preloadData.onPauseCb();
break;
case 'resume':
preloadData.onResumeCb();
break;
case 'stop':
playerWindowOpen = false;
window.open('../main_window/index.html', '_self');
break;
case 'seek':
preloadData.onSeekCb(null, message);
break;
case 'setvolume':
preloadData.onSetVolumeCb(null, message);
break;
case 'setspeed':
preloadData.onSetSpeedCb(null, message);
break;
default:
console.warn(`Unknown ipc message type: ${data[messageIndex].value}, value: ${data[dataIndex].value}`);
break;
}
});

View file

@ -0,0 +1,165 @@
import {
isLive,
onPlay,
player,
PlayerControlEvent,
playerCtrlCaptions,
playerCtrlDuration,
playerCtrlLiveBadge,
playerCtrlPosition,
playerCtrlProgressBar,
playerCtrlProgressBarBuffer,
playerCtrlProgressBarHandle,
playerCtrlProgressBarProgress,
playerCtrlStateUpdate,
playerCtrlVolumeBar,
playerCtrlVolumeBarHandle,
playerCtrlVolumeBarProgress,
videoCaptions,
formatDuration,
skipBack,
skipForward,
} from 'common/player/Renderer';
const captionsBaseHeightCollapsed = 150;
const captionsBaseHeightExpanded = 320;
const captionsLineHeight = 68;
enum RemoteKeyCode {
Stop = 413,
Rewind = 412,
Play = 415,
Pause = 19,
FastForward = 417,
Back = 10009,
MediaPlayPause = 10252,
}
tizen.tvinputdevice.registerKeyBatch(['MediaRewind',
'MediaFastForward', 'MediaPlay', 'MediaPause', 'MediaStop'
]);
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean {
let handledCase = false;
switch (event) {
case PlayerControlEvent.Load: {
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
if (isLive) {
playerCtrlLiveBadge.setAttribute("style", "display: block");
playerCtrlPosition.setAttribute("style", "display: none");
playerCtrlDuration.setAttribute("style", "display: none");
}
else {
playerCtrlLiveBadge.setAttribute("style", "display: none");
playerCtrlPosition.setAttribute("style", "display: block");
playerCtrlDuration.setAttribute("style", "display: block");
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
playerCtrlDuration.innerHTML = formatDuration(player.getDuration());
}
if (player.isCaptionsSupported()) {
// Disabling receiver captions control on TV players
playerCtrlCaptions.setAttribute("style", "display: none");
// playerCtrlCaptions.setAttribute("style", "display: block");
videoCaptions.setAttribute("style", "display: block");
}
else {
playerCtrlCaptions.setAttribute("style", "display: none");
videoCaptions.setAttribute("style", "display: none");
player.enableCaptions(false);
}
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
handledCase = true;
break;
}
default:
break;
}
return handledCase;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function targetKeyDownEventListener(event: any): boolean {
let handledCase = false;
switch (event.keyCode) {
case RemoteKeyCode.Stop:
window.open('../main_window/index.html');
handledCase = true;
break;
case RemoteKeyCode.Rewind:
skipBack();
event.preventDefault();
handledCase = true;
break;
case RemoteKeyCode.Play:
if (player.isPaused()) {
player.play();
}
event.preventDefault();
handledCase = true;
break;
case RemoteKeyCode.Pause:
if (!player.isPaused()) {
player.pause();
}
event.preventDefault();
handledCase = true;
break;
// Default behavior is to bring up a secondary menu where the user
// can use the arrow keys for other media controls, so don't handle
// this key manually
// case RemoteKeyCode.MediaPlayPause:
// if (!player.isPaused()) {
// player.pause();
// }
// else {
// player.play();
// }
// event.preventDefault();
// handledCase = true;
// break;
case RemoteKeyCode.FastForward:
skipForward();
event.preventDefault();
handledCase = true;
break;
case RemoteKeyCode.Back:
window.open('../main_window/index.html');
event.preventDefault();
handledCase = true;
break;
default:
break;
}
return handledCase;
};
if (window.tizenOSAPI.pendingPlay !== null) {
onPlay(null, window.tizenOSAPI.pendingPlay);
}
export {
captionsBaseHeightCollapsed,
captionsBaseHeightExpanded,
captionsLineHeight,
}

View file

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<title>FCast Receiver</title>
<meta charset="UTF-8">
<script type="text/javascript" src="$WEBAPIS/webapis/webapis.js"></script>
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<video id="videoPlayer" autoplay preload="auto"></video>
<div id="videoCaptions" class="captionsContainer"></div>
<div id="controls" class="container">
<div class="progressBarContainer">
<div id="progressBar" ref="progressBar" class="progressBar" ></div>
<div id="progressBarBuffer" class="progressBarBuffer" ></div>
<div id="progressBarProgress" class="progressBarProgress" ></div>
<div id="progressBarPosition" class="progressBarPosition" ></div>
<!-- <div class="progressBarChapterContainer"></div> -->
<div id="progressBarHandle" class="progressBarHandle" ></div>
<div id="progressBarInteractiveArea" class="progressBarInteractiveArea" ></div>
</div>
<div class="positionContainer">
<div id="liveBadge" class="liveBadge" style="display: none">LIVE</div>
<div id="position" class="position">00:00</div>
<!-- <div id="durationSeparator" class="duration">/&nbsp&nbsp</div> -->
<div id="durationSeparator" class="duration"></div>
</div>
<div class="leftButtonContainer">
<div id="action" class="play iconSize"></div>
<div id="volume" class="volume_high iconSize"></div>
<div class="volumeContainer">
<div id="volumeBar" ref="volumeBar" class="volumeBar" ></div>
<div id="volumeBarProgress" class="volumeBarProgress" ></div>
<div id="volumeBarHandle" class="volumeBarHandle" ></div>
<div id="volumeBarInteractiveArea" class="volumeBarInteractiveArea" ></div>
</div>
<!-- <div class="positionContainer">
<div id="liveBadge" class="liveBadge" style="display: none">LIVE</div>
<div id="position" class="position">00:00</div>
<div id="duration" class="duration">/&nbsp&nbsp00:00</div>
</div> -->
</div>
<div class="buttonContainer">
<!-- <div id="fullscreen" class="fullscreen_on iconSize"></div> -->
<div id="speed" class="speed iconSize"></div>
<div id="captions" class="captions_off iconSize"></div>
<div id="duration" class="duration">00:00</div>
</div>
<div id="speedMenu" class="speedMenu" style="display: none">
<div class="speedMenuTitle">Playback speed</div>
<div class="speedMenuSeparator"></div>
<div id="speedMenuEntry_0.25" class="speedMenuEntry">
<div id="speedMenuEntry_0.25_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">0.25</div>
</div>
<div id="speedMenuEntry_0.50" class="speedMenuEntry">
<div id="speedMenuEntry_0.50_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">0.5</div>
</div>
<div id="speedMenuEntry_0.75" class="speedMenuEntry">
<div id="speedMenuEntry_0.75_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">0.75</div>
</div>
<div id="speedMenuEntry_1.00" class="speedMenuEntry">
<div id="speedMenuEntry_1.00_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">1.0</div>
</div>
<div id="speedMenuEntry_1.25" class="speedMenuEntry">
<div id="speedMenuEntry_1.25_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">1.25</div>
</div>
<div id="speedMenuEntry_1.50" class="speedMenuEntry">
<div id="speedMenuEntry_1.50_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">1.5</div>
</div>
<div id="speedMenuEntry_1.75" class="speedMenuEntry">
<div id="speedMenuEntry_1.75_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">1.75</div>
</div>
<div id="speedMenuEntry_2.00" class="speedMenuEntry">
<div id="speedMenuEntry_2.00_enabled" class="speedMenuEntryEnabled"></div>
<div class="speedMenuEntryText">2.0</div>
</div>
</div>
</div>
<script src="./preload.js"></script>
<script src="./renderer.js"></script>
</body>
</html>

View file

@ -0,0 +1,182 @@
/* WebOS custom player styles */
.container {
height: 240px;
}
.iconSize {
width: 48px;
height: 48px;
background-size: cover;
}
#volume {
display: none;
}
.volumeContainer {
height: 48px;
width: 184px;
display: none;
}
.volumeBar {
left: 16px;
top: 20px;
height: 8px;
width: 152px;
}
.volumeBarInteractiveArea {
height: 48px;
width: 184px;
}
.volumeBarHandle {
left: 168px;
top: 8px;
width: 32px;
height: 32px;
box-shadow: 0px 64px 128px 0px rgba(0, 0, 0, 0.56), 0px 4px 42px 0px rgba(0, 0, 0, 0.55);
}
.volumeBarProgress {
left: 16px;
top: 20px;
height: 8px;
width: 152px;
}
.progressBarContainer {
bottom: 120px;
left: 32px;
right: 32px;
height: 8px;
padding-top: 20px;
padding-bottom: 20px;
}
.progressBarInteractiveArea {
height: 8px;
padding-top: 20px;
padding-bottom: 20px;
}
.progressBarChapterContainer {
bottom: 146px;
left: 48px;
right: 48px;
height: 8px;
}
.progressBar {
left: 16px;
width: calc(100% - 32px);
height: 8px;
}
.progressBarBuffer {
left: 16px;
bottom: 8px;
height: 8px;
}
.progressBarProgress {
left: 16px;
bottom: 16px;
height: 8px;
}
.progressBarPosition {
bottom: 50px;
padding: 4px 10px;
font-family: InterRegular;
font-size: 28px;
}
.progressBarHandle {
bottom: 20px;
width: 40px;
height: 40px;
margin-left: -16px;
margin-bottom: -16px;
}
.positionContainer {
position: absolute;
bottom: 48px;
left: 48px;
height: 48px;
font-family: InterRegular;
font-size: 28px;
flex-grow: unset;
}
.position {
font-family: InterRegular;
font-size: 28px;
/* margin-right: 20px; */
}
#duration {
font-family: InterRegular;
font-size: 28px;
}
.liveBadge {
padding: 4px 10px;
border-radius: 8px;
margin-right: 20px;
cursor: pointer;
}
.leftButtonContainer {
bottom: 48px;
left: 48px;
height: 48px;
/* right: 320px; */
right: 32px;
gap: 48px;
justify-content: center;
}
.buttonContainer {
bottom: 48px;
right: 48px;
height: 48px;
gap: 48px;
}
.captionsContainer {
/* display: none; */
/* position: relative; */
/* top: -200px; */
bottom: 320px;
/* margin: auto; */
/* text-align: center; */
/* font-family: InterVariable; */
font-family: InterRegular;
font-size: 56px;
/* font-style: normal; */
/* font-weight: 400; */
/* background-color: rgba(0, 0, 0, 0.5); */
padding: 0px 10px;
/* width: fit-content; */
/* user-select: none; */
/* transition: bottom 0.2s ease-in-out; */
}
#speed {
display: none;
}
#captions {
display: none;
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "ES2015",
"moduleResolution": "node10",
"sourceMap": false,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"removeComments": false,
"noImplicitAny": false,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"],
"modules/*": ["./node_modules/*"],
"common/*": ["../common/web/*"],
}
},
"exclude": [ "node_modules", "test" ]
}

View file

@ -0,0 +1,123 @@
const webpack = require('webpack');
const path = require('path');
const CopyWebpackPlugin = require("copy-webpack-plugin");
const buildMode = 'production';
// const buildMode = 'development';
// const TARGET = 'electron';
// const TARGET = 'webOS';
const TARGET = 'tizenOS';
module.exports = [
{
mode: buildMode,
entry: {
preload: './src/main/Preload.ts',
renderer: './src/main/Renderer.ts',
},
target: 'web',
module: {
rules: [
{
test: /\.tsx?$/,
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, 'src')],
use: [{ loader: 'ts-loader' }]
}
],
},
resolve: {
alias: {
'src': path.resolve(__dirname, 'src'),
'modules': path.resolve(__dirname, 'node_modules'),
'common': path.resolve(__dirname, '../common/web'),
},
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: '[name].js',
// NOTE: `dist/main` seems to be a reserved directory on the LGTV device??? Access denied errors otherwise when reading from main directory...
path: path.resolve(__dirname, 'dist/main_window'),
},
plugins: [
new CopyWebpackPlugin({
patterns: [
// Common assets
{
from: '../common/assets/**',
to: '../[path][name][ext]',
context: path.resolve(__dirname, '..', 'common'),
globOptions: { ignore: ['**/*.txt'] }
},
{
from: '../common/web/main/common.css',
to: '[name][ext]',
},
// Target assets
{ from: 'assets/icons/largeIcon.png', to: '../../FCastReceiver/icon.png' },
{ from: 'assets/icons/largeIcon.png', to: '../../FCastReceiverService/shared/res/icon.png' },
{
from: '**',
to: '../assets/[path][name][ext]',
context: path.resolve(__dirname, 'assets'),
},
{
from: './src/main/*',
to: '[name][ext]',
globOptions: { ignore: ['**/*.ts'] }
}
],
}),
new webpack.DefinePlugin({
TARGET: JSON.stringify(TARGET)
})
]
},
{
mode: buildMode,
entry: {
preload: './src/player/Preload.ts',
renderer: './src/player/Renderer.ts',
},
target: 'web',
module: {
rules: [
{
test: /\.tsx?$/,
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, 'src')],
use: [{ loader: 'ts-loader' }]
}
],
},
resolve: {
alias: {
'src': path.resolve(__dirname, 'src'),
'modules': path.resolve(__dirname, 'node_modules'),
'common': path.resolve(__dirname, '../common/web'),
},
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist/player'),
},
plugins: [
new CopyWebpackPlugin({
patterns: [
{
from: '../common/web/player/common.css',
to: '[name][ext]',
},
{
from: './src/player/*',
to: '[name][ext]',
globOptions: { ignore: ['**/*.ts'] }
}
],
}),
new webpack.DefinePlugin({
TARGET: JSON.stringify(TARGET)
})
]
},
];