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

Merge branch 'michael/tizen_v1-0-0' into 'master'

Merge Tizen receiver v1.0.0

See merge request videostreaming/fcast!11
This commit is contained in:
Michael Hollister 2025-04-14 19:46:17 +00:00
commit bf620b402a
45 changed files with 9397 additions and 0 deletions

View file

@ -3,6 +3,7 @@ stages:
- buildAndDeployAndroid
- buildAndDeployElectron
- buildWebOSReceiver
- buildTizenOSReceiver
variables:
ANDROID_VERSION_NAME:
@ -16,3 +17,4 @@ include:
- local: 'receivers/android/.gitlab-ci.yml'
- local: 'receivers/electron/.gitlab-ci.yml'
- local: 'receivers/webos/.gitlab-ci.yml'
- local: 'receivers/tizen/.gitlab-ci.yml'

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

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

View file

@ -0,0 +1,34 @@
buildTizenOSDockerContainer:
stage: buildDockerContainers
image: docker:20.10.16
services:
- docker:20.10.16-dind
tags:
- fcast-instance-runner
before_script:
- cd receivers/tizen
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- docker build -t $CI_REGISTRY/videostreaming/fcast/receiver-tizen-dev:latest .
- docker push $CI_REGISTRY/videostreaming/fcast/receiver-tizen-dev:latest
when: manual
buildTizenOSReceiver:
stage: buildTizenOSReceiver
image: gitlab.futo.org:5050/videostreaming/fcast/receiver-tizen-dev:latest
tags:
- fcast-instance-runner
before_script:
- cd receivers/tizen
script:
- npm install
- scripts/build.sh
artifacts:
untracked: false
when: on_success
access: all
expire_in: "30 days"
paths:
- receivers/tizen/FCastReceiver/.buildResult/FCastReceiver.wgt
when: manual

View file

@ -0,0 +1,25 @@
# FROM ubuntu:20.04
FROM node:23.8.0-bullseye
USER root
RUN apt update && apt install -y wget expect
RUN useradd -ms /bin/bash ubuntu
RUN usermod -a -G root ubuntu
# Tizen Studio installer requires non-root user install
USER ubuntu
WORKDIR /home/ubuntu
RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh
RUN chmod +x ./dotnet-install.sh
RUN ./dotnet-install.sh --channel 2.1
ENV DOTNET_ROOT=/home/ubuntu/.dotnet
ENV PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools
RUN wget https://download.tizen.org/sdk/Installer/tizen-studio_6.0/web-cli_Tizen_Studio_6.0_ubuntu-64.bin
RUN chmod +x web-cli_Tizen_Studio_6.0_ubuntu-64.bin
RUN yes | ./web-cli_Tizen_Studio_6.0_ubuntu-64.bin --accept-license
RUN /home/ubuntu/tizen-studio/package-manager/package-manager-cli.bin install --accept-license Baseline-SDK WebCLI TV-SAMSUNG-Public-WebAppDevelopment cert-add-on TV-SAMSUNG-Extension-Tools
ENV PATH=$PATH:/home/ubuntu/tizen-studio/tools/:/home/ubuntu/tizen-studio/tools/ide/bin/
USER root

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

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

View file

@ -0,0 +1,266 @@
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...
// May need to investigate further however, perhaps its only an issue when running in emulator
// No API to get Tizen version, so have to go by OS image build date...
// Format: YYYYMMDD_HHMMSS
if (int.Parse(SystemInformation.BuildDate.Substring(0, 4)) > 2018)
{
websocketsSupported = true;
webSocketListenerService = new WebSocketListnerService();
listeners.Add(webSocketListenerService);
}
foreach (IListenerService l in listeners)
{
l.OnPlay += Program.OnPlay;
l.OnPause += (object sender, EventArgs e) => { SendAppMessage("pause"); };
l.OnResume += (object sender, EventArgs e) => { SendAppMessage("resume"); };
l.OnStop += (object sender, EventArgs e) => { SendAppMessage("stop"); };
l.OnSeek += (object sender, SeekMessage e) =>
{
SendAppMessage("seek", JsonSerializer.Serialize(e));
};
l.OnSetVolume += (object sender, SetVolumeMessage e) =>
{
SendAppMessage("setvolume", JsonSerializer.Serialize(e));
};
l.OnSetSpeed += (object sender, SetSpeedMessage e) =>
{
SendAppMessage("setspeed", JsonSerializer.Serialize(e));
};
l.OnPing += (object sender, Dictionary<string, string> e) => { SendAppMessage("ping", e); };
l.OnConnect += (object sender, Dictionary<string, string> e) => { SendAppMessage("connect", e); };
l.OnDisconnect += (object sender, Dictionary<string, string> e) => { SendAppMessage("disconnect", e); };
l.ListenAsync();
}
SendAppMessage("serviceStarted", new Dictionary<string, string>() {
{ "websocketsSupported", websocketsSupported.ToString() },
{ "buildDate", SystemInformation.BuildDate },
{ "buildId", SystemInformation.BuildId },
{ "buildRelease", SystemInformation.BuildRelease },
{ "buildString", SystemInformation.BuildString },
});
}
protected override void OnTerminate()
{
SendAppMessage("serviceExit");
tcpDnssdService.DeregisterService();
tcpDnssdService.Dispose();
wsDnssdService.DeregisterService();
wsDnssdService.Dispose();
base.OnTerminate();
}
private static void OnPlay(object sender, PlayMessage e)
{
if (!ApplicationManager.IsRunning(AppId))
{
Serilog.Log.Information("FCast application not running, launching application");
AppControl.SendLaunchRequest(_appControl);
ReattemptOnPlay(sender, e);
return;
}
else
{
ApplicationRunningContext appContext = new ApplicationRunningContext(AppId);
if (appContext.State == ApplicationRunningContext.AppState.Background)
{
Serilog.Log.Information("FCast application suspended, resuming");
appContext.Resume();
ReattemptOnPlay(sender, e);
return;
}
}
e = NetworkService.ProxyPlayIfRequired(e);
Serilog.Log.Information($"Sending play message: {e}");
SendAppMessage("play", JsonSerializer.Serialize(e));
}
private static void ReattemptOnPlay(object sender, PlayMessage e)
{
Task.Run(async () =>
{
int delay = 1000;
while (true)
{
// Drop play message after ~20s if app does not startup or resume to foreground
if (delay > 6000)
{
return;
}
if (ApplicationManager.IsRunning(AppId))
{
ApplicationRunningContext appContext = new ApplicationRunningContext(AppId);
if (appContext.State == ApplicationRunningContext.AppState.Foreground)
{
OnPlay(sender, e);
return;
}
}
Serilog.Log.Information($"Waiting {delay}ms for application to start");
await Task.Delay(delay);
delay += 1000;
}
});
}
public static void SendAppMessage(string message, Dictionary<string, string> data)
{
SendAppMessage(message, JsonSerializer.Serialize(data));
}
public static void SendAppMessage(string message, string data = "null")
{
if (_appPort.IsRunning())
{
Bundle bundle = new Bundle();
bundle.AddItem("message", message);
bundle.AddItem("data", data);
IpcPort.Send(bundle, AppId, AppPort);
}
else
{
Serilog.Log.Warning($"App is currently not running, cannot send message: {message} {data}");
}
}
private static void RemotePortStateChanged(object sender, RemotePortStateChangedEventArgs e)
{
switch (e.Status)
{
case State.Registered:
Serilog.Log.Information("Remote ipc port is registered");
break;
case State.Unregistered:
Serilog.Log.Information("Remote ipc port is unregistered");
break;
default:
break;
}
}
private static void IpcMainMessageCb(object sender, MessageReceivedEventArgs e)
{
Serilog.Log.Information($"Message received in main handler with {e.Message.Count} items");
e.Message.TryGetItem("command", out string command);
switch (command)
{
case "getSystemInfo":
SendAppMessage("getSystemInfo", new Dictionary<string, string>() {
{ "websocketsSupported", websocketsSupported.ToString() },
{ "buildDate", SystemInformation.BuildDate },
{ "buildId", SystemInformation.BuildId },
{ "buildRelease", SystemInformation.BuildRelease },
{ "buildString", SystemInformation.BuildString },
});
break;
default:
Serilog.Log.Information($"Unknown message with command {command}");
break;
}
}
public static void DebugAppMessage(string message, int icon = 0)
{
SendAppMessage("toast", new Dictionary<string, string>() { { "message", message }, { "icon", icon.ToString() } });
}
static void Main(string[] args)
{
try
{
Serilog.Log.Logger = new Serilog.LoggerConfiguration().WriteTo.Debug().CreateLogger();
var app = new Program();
app.Run(args);
}
catch (Exception e)
{
Serilog.Log.Error($"Network service: {e}");
DebugAppMessage($"Network service: {e}", 1);
}
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,25 @@

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,129 @@

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

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

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

@ -0,0 +1,41 @@
# FCast Tizen OS Receiver
The FCast Tizen OS Receiver is split into two separate projects `FCastReceiver` for frontend UI and `FCastReceiverService` for the background network service. The WebOS receiver is supported running on TV devices from Tizen OS 5.0 and later.
The TV receiver player is using the same simplified player used in the webOS receiver implementation. Future versions might support a more advanced player like the Electron player since Tizen OS video player is less limited compared to webOS.
# How to build
## Preparing for build
A docker file is provided to setup your build environment. From the root of the repository:
* **Build:**: `docker build -t fcast/receiver-tizen-dev:latest receivers/tizen`
**Run:**
```bash
docker run --rm -it -w /app/receivers/tizen --env-file=./receivers/tizen/.env \
--entrypoint='bash' -p 26099:26099 -p 26101:26101 -v .:/app \
fcast/receiver-tizen-dev:latest
```
You can then run the following commands to finish setup inside the docker container.
```
npm install
```
For signing the build artifact you must export the following environment variables or set them in your `.env` file:
```
CERT_PATH=/app/receivers/tizen/PATH_TO_CERTS
CERT_IDENTITY=YOUR_IDENTITY
CERT_AUTHOR_PASSWORD=YOUR_PASSWORD
CERT_DIST_PASSWORD=YOUR_PASSWORD
```
Directory structure should be as follows for storing certificates:
* Author certificates: `$CERT_PATH/author/$CERT_IDENTITY/author.p12`
* Distributor certificates: `$CERT_PATH/SamsungCertificate/$CERT_IDENTITY/distributor.p12`
## Build
To build the `.wgt` package run `scripts/build.sh`. Build artifact will be located at `REPO_ROOT/receivers/tizen/FCastReceiver/.buildResult/FCastReceiver.wgt`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,8 @@
@REM Local development build script
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 ../../

View file

@ -0,0 +1,31 @@
#!/bin/bash
# Docker container build script
npm run build
cd FCastReceiverService
dotnet build -c Release
cd ..
cd FCastReceiver
tizen build-web -- .
cd .buildResult
# Tizen OS typically uses GNOME keyring to store certificate passwords. However setting up keying
# requires dbus access and is dependent on the host envrionment. The second alternative is to put
# passwords directly in profiles.xml, but after every package it overwrites the password entries, so
# it has to be regenerated on every packaging...
# https://stackoverflow.com/a/61718469
tizen security-profiles add --active --force --name $CERT_IDENTITY --author $CERT_PATH/author/$CERT_IDENTITY/author.p12 --password $CERT_AUTHOR_PASSWORD --dist $CERT_PATH/SamsungCertificate/$CERT_IDENTITY/distributor.p12 --dist-password $CERT_DIST_PASSWORD
tizen cli-config "profiles.path=/home/ubuntu/tizen-studio-data/profile/profiles.xml"
sed -i "s|$CERT_PATH/author/$CERT_IDENTITY/author.pwd|$CERT_AUTHOR_PASSWORD|g" /home/ubuntu/tizen-studio-data/profile/profiles.xml
sed -i "s|$CERT_PATH/SamsungCertificate/$CERT_IDENTITY/distributor.pwd|$CERT_DIST_PASSWORD|g" /home/ubuntu/tizen-studio-data/profile/profiles.xml
../../scripts/package.sh tizen package -t wgt -s $CERT_IDENTITY -- .
tizen security-profiles add --active --force --name $CERT_IDENTITY --author $CERT_PATH/author/$CERT_IDENTITY/author.p12 --password $CERT_AUTHOR_PASSWORD --dist $CERT_PATH/SamsungCertificate/$CERT_IDENTITY/distributor.p12 --dist-password $CERT_DIST_PASSWORD
tizen cli-config "profiles.path=/home/ubuntu/tizen-studio-data/profile/profiles.xml"
sed -i "s|$CERT_PATH/author/$CERT_IDENTITY/author.pwd|$CERT_AUTHOR_PASSWORD|g" /home/ubuntu/tizen-studio-data/profile/profiles.xml
sed -i "s|$CERT_PATH/SamsungCertificate/$CERT_IDENTITY/distributor.pwd|$CERT_DIST_PASSWORD|g" /home/ubuntu/tizen-studio-data/profile/profiles.xml
mv "FCast Receiver.wgt" FCastReceiver.wgt
../../scripts/package.sh tizen package -t wgt -s $CERT_IDENTITY -r ../../FCastReceiverService/bin/Release/netcoreapp2.1/com.futo.FCastReceiverService-1.0.0.tpk -- FCastReceiver.wgt
cd ../../

View file

@ -0,0 +1,13 @@
#!/bin/bash
# Local development build script
npm run build
cd FCastReceiverService
dotnet build -c Release
cd ..
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 ../../

View file

@ -0,0 +1,13 @@
@REM Local development debug script
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 Emulators
@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
@REM Samsung remote lab
@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

View file

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

View file

@ -0,0 +1,16 @@
#!/usr/bin/expect -f
set timeout -1
spawn {*}$argv
expect "Author password: "
send -- "$env(CERT_AUTHOR_PASSWORD)\n"
expect "Yes: (Y), No: (N) ?"
send -- "n\n"
expect "Distributor1 password: "
send -- "$env(CERT_DIST_PASSWORD)\n"
expect "Yes: (Y), No: (N) ?"
send -- "n\n"
expect eof

View file

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

View file

@ -0,0 +1,32 @@
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;
// eslint-disable-next-line no-var
var loadPollCount = 0;
// eslint-disable-next-line no-var
var loadScreenDone = setInterval(() => {
// Show main screen regardless if resources not loaded within 10s
if ((backgroundVideoLoaded && qrCodeRendered) || loadPollCount > 10) {
clearInterval(loadScreenDone);
loadingScreen.style.display = 'none';
}
loadPollCount++;
}, 1000);
backgroundVideo.onplaying = () => {
backgroundVideoLoaded = true;
backgroundVideo.onplaying = null;
};
export function onQRCodeRendered() {
qrCodeRendered = true;
}

View file

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

View file

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

View file

@ -0,0 +1,94 @@
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) }
]);
};
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 (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':
window.open('../main_window/index.html', '_self');
break;
case 'seek':
preloadData.onSeekCb(null, message);
break;
case 'setvolume':
preloadData.onSetVolumeCb(null, message);
break;
case 'setspeed':
preloadData.onSetSpeedCb(null, message);
break;
default:
console.warn(`Unknown ipc message type: ${data[messageIndex].value}, value: ${data[dataIndex].value}`);
break;
}
});

View file

@ -0,0 +1,165 @@
import {
isLive,
onPlay,
player,
PlayerControlEvent,
playerCtrlCaptions,
playerCtrlDuration,
playerCtrlLiveBadge,
playerCtrlPosition,
playerCtrlProgressBar,
playerCtrlProgressBarBuffer,
playerCtrlProgressBarHandle,
playerCtrlProgressBarProgress,
playerCtrlStateUpdate,
playerCtrlVolumeBar,
playerCtrlVolumeBarHandle,
playerCtrlVolumeBarProgress,
videoCaptions,
formatDuration,
skipBack,
skipForward,
} from 'common/player/Renderer';
const captionsBaseHeightCollapsed = 150;
const captionsBaseHeightExpanded = 320;
const captionsLineHeight = 68;
enum RemoteKeyCode {
Stop = 413,
Rewind = 412,
Play = 415,
Pause = 19,
FastForward = 417,
Back = 10009,
MediaPlayPause = 10252,
}
tizen.tvinputdevice.registerKeyBatch(['MediaRewind',
'MediaFastForward', 'MediaPlay', 'MediaPause', 'MediaStop'
]);
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean {
let handledCase = false;
switch (event) {
case PlayerControlEvent.Load: {
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
if (isLive) {
playerCtrlLiveBadge.setAttribute("style", "display: block");
playerCtrlPosition.setAttribute("style", "display: none");
playerCtrlDuration.setAttribute("style", "display: none");
}
else {
playerCtrlLiveBadge.setAttribute("style", "display: none");
playerCtrlPosition.setAttribute("style", "display: block");
playerCtrlDuration.setAttribute("style", "display: block");
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
playerCtrlDuration.innerHTML = formatDuration(player.getDuration());
}
if (player.isCaptionsSupported()) {
// Disabling receiver captions control on TV players
playerCtrlCaptions.setAttribute("style", "display: none");
// playerCtrlCaptions.setAttribute("style", "display: block");
videoCaptions.setAttribute("style", "display: block");
}
else {
playerCtrlCaptions.setAttribute("style", "display: none");
videoCaptions.setAttribute("style", "display: none");
player.enableCaptions(false);
}
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
handledCase = true;
break;
}
default:
break;
}
return handledCase;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function targetKeyDownEventListener(event: any): boolean {
let handledCase = false;
switch (event.keyCode) {
case RemoteKeyCode.Stop:
window.open('../main_window/index.html', '_self');
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', '_self');
event.preventDefault();
handledCase = true;
break;
default:
break;
}
return handledCase;
};
if (window.tizenOSAPI.pendingPlay !== null) {
onPlay(null, window.tizenOSAPI.pendingPlay);
}
export {
captionsBaseHeightCollapsed,
captionsBaseHeightExpanded,
captionsLineHeight,
}

View file

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

View file

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

View file

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

View file

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