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:
parent
8c2eb78ef5
commit
2e5746645f
40 changed files with 9881 additions and 0 deletions
10
receivers/tizen/.gitignore
vendored
Normal file
10
receivers/tizen/.gitignore
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
/.metadata/
|
||||
dist
|
||||
|
||||
FCastReceiver/.buildResult
|
||||
FCastReceiver/.settings
|
||||
FCastReceiver/.sign
|
||||
|
||||
FCastReceiverService/.vs
|
||||
FCastReceiverService/bin
|
||||
FCastReceiverService/obj
|
30
receivers/tizen/FCastReceiver/.project
Normal file
30
receivers/tizen/FCastReceiver/.project
Normal 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>
|
11
receivers/tizen/FCastReceiver/.tproject
Normal file
11
receivers/tizen/FCastReceiver/.tproject
Normal 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>
|
15
receivers/tizen/FCastReceiver/config.xml
Normal file
15
receivers/tizen/FCastReceiver/config.xml
Normal 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>
|
BIN
receivers/tizen/FCastReceiver/icon.png
Normal file
BIN
receivers/tizen/FCastReceiver/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
12
receivers/tizen/FCastReceiver/index.html
Normal file
12
receivers/tizen/FCastReceiver/index.html
Normal 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>
|
265
receivers/tizen/FCastReceiverService/FCastReceiverService.cs
Normal file
265
receivers/tizen/FCastReceiverService/FCastReceiverService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
@ -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>
|
|
@ -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
|
312
receivers/tizen/FCastReceiverService/FCastSession.cs
Normal file
312
receivers/tizen/FCastReceiverService/FCastSession.cs
Normal 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();
|
||||
}
|
||||
}
|
25
receivers/tizen/FCastReceiverService/IListenerService.cs
Normal file
25
receivers/tizen/FCastReceiverService/IListenerService.cs
Normal 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();
|
||||
}
|
||||
}
|
179
receivers/tizen/FCastReceiverService/NetworkService.cs
Normal file
179
receivers/tizen/FCastReceiverService/NetworkService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
98
receivers/tizen/FCastReceiverService/Packets.cs
Normal file
98
receivers/tizen/FCastReceiverService/Packets.cs
Normal 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; }
|
||||
}
|
129
receivers/tizen/FCastReceiverService/SystemInformation.cs
Normal file
129
receivers/tizen/FCastReceiverService/SystemInformation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
168
receivers/tizen/FCastReceiverService/TcpListenerService.cs
Normal file
168
receivers/tizen/FCastReceiverService/TcpListenerService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
157
receivers/tizen/FCastReceiverService/WebSocketListenerService.cs
Normal file
157
receivers/tizen/FCastReceiverService/WebSocketListenerService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
49
receivers/tizen/FCastReceiverService/WebSocketStream.cs
Normal file
49
receivers/tizen/FCastReceiverService/WebSocketStream.cs
Normal 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();
|
||||
}
|
||||
}
|
BIN
receivers/tizen/FCastReceiverService/shared/res/icon.png
Normal file
BIN
receivers/tizen/FCastReceiverService/shared/res/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
19
receivers/tizen/FCastReceiverService/tizen-manifest.xml
Normal file
19
receivers/tizen/FCastReceiverService/tizen-manifest.xml
Normal 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
33
receivers/tizen/README.md
Normal 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
|
||||
```
|
BIN
receivers/tizen/assets/icons/largeIcon.png
Normal file
BIN
receivers/tizen/assets/icons/largeIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
6
receivers/tizen/build.bat
Normal file
6
receivers/tizen/build.bat
Normal 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
8
receivers/tizen/build.sh
Normal 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
13
receivers/tizen/debug.bat
Normal 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
5
receivers/tizen/debug.sh
Normal 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
|
11
receivers/tizen/eslint.config.mjs
Normal file
11
receivers/tizen/eslint.config.mjs
Normal 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,
|
||||
];
|
6
receivers/tizen/jest.config.js
Normal file
6
receivers/tizen/jest.config.js
Normal 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
7211
receivers/tizen/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
48
receivers/tizen/package.json
Normal file
48
receivers/tizen/package.json
Normal 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"
|
||||
}
|
||||
}
|
121
receivers/tizen/src/main/Preload.ts
Normal file
121
receivers/tizen/src/main/Preload.ts
Normal 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;
|
||||
}
|
||||
});
|
27
receivers/tizen/src/main/Renderer.ts
Normal file
27
receivers/tizen/src/main/Renderer.ts
Normal 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';
|
||||
}
|
||||
}
|
62
receivers/tizen/src/main/index.html
Normal file
62
receivers/tizen/src/main/index.html
Normal 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>
|
96
receivers/tizen/src/main/style.css
Normal file
96
receivers/tizen/src/main/style.css
Normal 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;
|
||||
}
|
100
receivers/tizen/src/player/Preload.ts
Normal file
100
receivers/tizen/src/player/Preload.ts
Normal 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;
|
||||
}
|
||||
});
|
165
receivers/tizen/src/player/Renderer.ts
Normal file
165
receivers/tizen/src/player/Renderer.ts
Normal 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,
|
||||
}
|
99
receivers/tizen/src/player/index.html
Normal file
99
receivers/tizen/src/player/index.html
Normal 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">/  </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">/  00: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>
|
182
receivers/tizen/src/player/style.css
Normal file
182
receivers/tizen/src/player/style.css
Normal 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;
|
||||
}
|
21
receivers/tizen/tsconfig.json
Normal file
21
receivers/tizen/tsconfig.json
Normal 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" ]
|
||||
}
|
123
receivers/tizen/webpack.config.js
Normal file
123
receivers/tizen/webpack.config.js
Normal 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)
|
||||
})
|
||||
]
|
||||
},
|
||||
];
|
Loading…
Add table
Add a link
Reference in a new issue