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:
commit
bf620b402a
45 changed files with 9397 additions and 0 deletions
|
@ -3,6 +3,7 @@ stages:
|
||||||
- buildAndDeployAndroid
|
- buildAndDeployAndroid
|
||||||
- buildAndDeployElectron
|
- buildAndDeployElectron
|
||||||
- buildWebOSReceiver
|
- buildWebOSReceiver
|
||||||
|
- buildTizenOSReceiver
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
ANDROID_VERSION_NAME:
|
ANDROID_VERSION_NAME:
|
||||||
|
@ -16,3 +17,4 @@ include:
|
||||||
- local: 'receivers/android/.gitlab-ci.yml'
|
- local: 'receivers/android/.gitlab-ci.yml'
|
||||||
- local: 'receivers/electron/.gitlab-ci.yml'
|
- local: 'receivers/electron/.gitlab-ci.yml'
|
||||||
- local: 'receivers/webos/.gitlab-ci.yml'
|
- local: 'receivers/webos/.gitlab-ci.yml'
|
||||||
|
- local: 'receivers/tizen/.gitlab-ci.yml'
|
||||||
|
|
12
receivers/tizen/.gitignore
vendored
Normal file
12
receivers/tizen/.gitignore
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/.metadata/
|
||||||
|
dist
|
||||||
|
certs
|
||||||
|
|
||||||
|
.env
|
||||||
|
FCastReceiver/.buildResult
|
||||||
|
FCastReceiver/.settings
|
||||||
|
FCastReceiver/.sign
|
||||||
|
|
||||||
|
FCastReceiverService/.vs
|
||||||
|
FCastReceiverService/bin
|
||||||
|
FCastReceiverService/obj
|
34
receivers/tizen/.gitlab-ci.yml
Normal file
34
receivers/tizen/.gitlab-ci.yml
Normal 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
|
25
receivers/tizen/Dockerfile
Normal file
25
receivers/tizen/Dockerfile
Normal 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
|
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>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>
|
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>
|
266
receivers/tizen/FCastReceiverService/FCastReceiverService.cs
Normal file
266
receivers/tizen/FCastReceiverService/FCastReceiverService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
41
receivers/tizen/README.md
Normal file
41
receivers/tizen/README.md
Normal 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`.
|
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 |
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"],
|
||||||
|
};
|
6601
receivers/tizen/package-lock.json
generated
Normal file
6601
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"
|
||||||
|
}
|
||||||
|
}
|
8
receivers/tizen/scripts/build.bat
Normal file
8
receivers/tizen/scripts/build.bat
Normal 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 ../../
|
31
receivers/tizen/scripts/build.sh
Executable file
31
receivers/tizen/scripts/build.sh
Executable 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 ../../
|
13
receivers/tizen/scripts/build_local.sh
Executable file
13
receivers/tizen/scripts/build_local.sh
Executable 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 ../../
|
13
receivers/tizen/scripts/debug.bat
Normal file
13
receivers/tizen/scripts/debug.bat
Normal 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
|
||||||
|
|
6
receivers/tizen/scripts/debug.sh
Executable file
6
receivers/tizen/scripts/debug.sh
Executable 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
|
16
receivers/tizen/scripts/package.sh
Executable file
16
receivers/tizen/scripts/package.sh
Executable 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
|
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;
|
||||||
|
}
|
||||||
|
});
|
32
receivers/tizen/src/main/Renderer.ts
Normal file
32
receivers/tizen/src/main/Renderer.ts
Normal 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;
|
||||||
|
}
|
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;
|
||||||
|
}
|
94
receivers/tizen/src/player/Preload.ts
Normal file
94
receivers/tizen/src/player/Preload.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
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', '_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,
|
||||||
|
}
|
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