Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 146 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,154 @@
![GitHub Release](https://img.shields.io/github/v/release/UniScanProject/UniScan?sort=date&filter=UniScan.Server.Host%2F*&display_name=tag&label=Server)
</div>

Modular scanner/radio client and multi-broadcast server. Plug in a scanner, set it up in the server software, and open
Modular, cross-platform scanner/radio client and multi-broadcast server. Plug in a scanner, set it up in the server software, and open
it up to the internet for others to listen to, and control it yourself by logging into an admin user.

Modular meaning that custom built modules will be able to fully extend the client and server via hooks, event listening, configuration stages, and more all without requiring e.g. Harmony hooks for most simple extension needs.

The client works by connecting to a "Remote", which contains devices that the client can 'subscribe' to receive updates, see current scanning info, and (soon) listen to any device's configured audio track(s).

Remotes require a plugin that adds support for whatever devices plan to be connected, with device pages being rendered on the serverside and sent to the client.
This allows the client to not require too much special setup to communicate with virtually any Remote.

In the future, there will be easy ways for hosters to embed the UniScan client in their website with a preset configuration to automatically add and connect to their server(s).

<table>
<tr>
<td align="center" width="33%">
<img src="./repo_assets/device_page.png" alt="UniScan receiving and displaying data from a remote SDS100" width="100%">
<br>
<b>UniScan receiving and displaying data from a remote SDS100</b>
</td>
<td align="center" width="33%">
<img src="./repo_assets/remote_page.png" alt="UniScan connected to a "remote" that has one device" width="100%">
<br>
<b>UniScan connected to a "remote" that has one device</b>
</td>
</tr>
</table>

### Roadmap
Our full roadmap can be viewed on our [GitHub Project](https://github.com/orgs/UniScanProject/projects/2)

#### (rough) Feature roadmap
- [X] Module support
- Official Extension Modules
- [ ] Virtual Scanner debug module
- [ ] Transcript module (via some STT model)

##### Server
- [X] Modular custom scanner support framework
- [X] Networking
- [X] Support for multiple network hosting methods (TCP, Websocket (via module))
- [X] Serverside rendering of scanner info to send to the client
- [ ] Serverside rendering of server,serverside plugin options to send to the client
- [ ] Serverside rendering of a scanner's display to send to the client
- [ ] Custom serverside rendered pages to send to the Client
- [ ] Scanner audio track streaming
- By audio track, I mean having multiple audio tracks, since I believe some radio devices can have multiple outputs? I don't fully know yet.
- Either way, no reason to not have multiple.
- [ ] Users and Authentication
- [ ] Announcement posting from users
- [ ] Remote scanner management from users with the correct permissions
- [ ] Scanner programming (may not happen)

##### Client
- [X] Connecting to remotes
- [X] Displaying remote info (name,description,links,logo,announcements,etc)
- [X] Displaying live device info (such as current channel, scan list, etc)
- [ ] Listening to audio tracks
- [ ] Input optimized UI
- i.e. better UI for tv remotes,controllers on Android TV, better UI for narrow touchscreens on Android,iOS.
- But all will be accessible crossplatform anyway
- [ ] Nicer theme
- [ ] User login
- [ ] Rendering of custom serverside rendered pages created by the server
- [ ] Internal server for connecting local scanners without the need for hosting

## Setup
UniScan currently supports Windows, macOS, Linux, Android, and iOS (kind of)
In addition to these platforms, UniScan can also run [entirely in your browser!](https://uniscan.dexrn.me)

> [!IMPORTANT]
> Browser support is currently broken due to some OPFS garbage, sorry.
>
> will fix soon hopefully.

Additionally, Android TV does work, but no optimized UI has been made as of now.

Below, you can learn how to setup and install UniScan for your platform.
#### Windows
- [Install .NET 10](https://dot.net/download)
- Download the latest Windows release from the releases tab (or via GitHub Actions artifacts)
- Unzip it
- Open UniScan.Client.App.Platform.Desktop.exe

#### Linux
- [Install .NET 10](https://dot.net/download) (usually via your distro's package manager)
- Download the latest Linux release from the releases tab (or via GitHub Actions artifacts)
- Unzip it
- Open a terminal cd'd to the unzipped folder
- Run `chmod +x ./UniScan.Client.App.Platform.Desktop`
- Now you can open UniScan.Client.App.Platform.Desktop
- A small oversight is that some file managers will associate e.g. Kate with .desktop files despite the file being an exec
- You may need to use the terminal to run UniScan in this case until something is figured out

> [!NOTE]
> There is no packaging available for Linux yet (not coming until at least the first Alpha release)

#### MacOS
- [Install .NET 10](https://dot.net/download)
- Download the latest MacOS release from the releases tab (or via GitHub Actions artifacts)
- Note that this is unpackaged due to package testing being very hard as I don't own a Mac
- Unzip it (if using Archive Manager, double click in Finder)
- Open a Terminal
- Unquarantine the folder via `xattr -dr com.apple.quarantine {Path}`
- CD into the path with the already open terminal
- run `chmod +x ./UniScan.Client.App.Platform.Desktop`
- Run `UniScan.Client.App.Platform.Desktop`

> [!NOTE]
> There is no packaging available for MacOS yet mainly due to the fact that its almost impossible for me to test as I do not own a Mac
> Thanks Apple for locking down everything............
>
> However, I do intend to make packaging available for all platforms during the alpha or release stage.

#### Android
- Download the latest Android APK from the releases tab
- Also available in the GitHub Actions artifacts, but you must download the one with "signed" in the name.
- Open the file, then tap Install (in Package Installer)
- It may require you to enable "Install unknown apps" if you have never installed an APK from the app you are opening it from before

> [!NOTE]
> UniScan is not yet available on F-Droid, but this will come soon.

##### Android TV
> [!IMPORTANT]
> This specific install guide requires knowledge of ADB, and a device capable of running an ADB client
>
> This guide also assumes you have no other devices currently connected on the client's ADB daemon

- Download the latest Android APK from the releases tab
- Note that depending on your tv box model and architecture, you may need the `arm` version and NOT the `arm64` version.
- You can figure this out usually by googling for your TV box's model and finding the specs which should list the CPU, then another search to find the architecture of that CPU.
- `arm32`/`armhf`/`arm-v7a` corresponds to the `arm` APK, while `arm64`/`arm-v{8+}a` corresponds to the `arm64` APK
- Enable Developer Mode via clicking the OK button on "Build Number" ~10 times on the About screen in Settings
- Go to the "Developer Options" page in Settings
- Enable "USB Debugging" and "Wireless Debugging"
- Go inside the "Wireless Debugging" page
- Press the OK button on "Pair device with pairing code"
- You should see it show the TV's local IP address, a port number, and a 'pairing code', note these down. (like in your head or something)
- On your client device, run `adb pair {TV IP address}:{TV ADB port} {TV pairing code}`
- Once it pairs, run `adb connect {TV IP address}:{TV ADB port}`
- Now, in the same folder as the UniScan APK you downloaded, run `adb install {APK file path}`

#### iOS
> [!IMPORTANT]
> It is unknown whether the non-emulator IPA can be installed on sideloading enabled devices.

**TODO** as I don't have access to a simulator as of writing this.

## Subprojects

| Name | Version | Description |
Expand Down
Binary file added repo_assets/device_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added repo_assets/remote_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/Audio/UniScan.Audio/UniScan.Audio.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@
<Platforms>AnyCPU</Platforms>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\Network\UniScan.Network\UniScan.Network.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,23 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="UniScan.Client.App.Views.Remote.Device.DevicePageView"
x:DataType="device:DevicePageViewModel">
<ssr:UISlotControl DataContext="{Binding SSRViewModel}" />
<DockPanel>
<Border Background="{StaticResource SystemControlBackgroundBaseLowBrush}" CornerRadius="5" Padding="5" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Command="{Binding RemotePageNavigator.NavigateCommand}" CommandParameter="{x:Null}" VerticalAlignment="Center">
<TextBlock FontFamily="{StaticResource MaterialSymbolsRounded}"
Text="arrow_back"
FontSize="24"
Width="24"
Height="24"
Margin="0 0 0 0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center" />
</Button>
<TextBlock Text="{Binding DeviceViewModel.Device.Name}" FontWeight="Bold" FontSize="16" VerticalAlignment="Center" />
</StackPanel>
</Border>
<ssr:UISlotControl DataContext="{Binding SSRViewModel}" />
</DockPanel>
</UserControl>
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ public class DevicePageViewModel : ViewModelBase
public DeviceViewModel DeviceViewModel { get; }

public SSRSlotContext SSRViewModel { get; }

public IRemoteRootPageDeviceNavigatorProxy RemotePageNavigator { get; }

public DevicePageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry)
public DevicePageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry, IRemoteRootPageDeviceNavigatorProxy remoteNavigator)
{
DeviceViewModel = deviceViewModel;
RemotePageNavigator = remoteNavigator;

Identifier id = new("UniScan", "ssr", "slot", "device", DeviceViewModel.Device.Identifier);
if (registry.TryGet(id, out SSRSlotContext? vm))//stupid hack because for some reason it keeps creating new SSRViewModel or something weird
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ public class DeviceRootPageViewModel : SubPagedViewModelBase, IDisposable

private DevicePageViewModel _mainPage;

public DeviceRootPageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry) : base(new EmptyPageViewModel(), UniScanApp.Identifier.Derived("view_model", "device", deviceViewModel.Device.Identifier))
public DeviceRootPageViewModel(DeviceViewModel deviceViewModel, IUISlotRegistry registry, IRemoteRootPageDeviceNavigatorProxy remoteNavigator) : base(new EmptyPageViewModel(), UniScanApp.Identifier.Derived("view_model", "device", deviceViewModel.Device.Identifier))
{
DeviceViewModel = deviceViewModel;

_mainPage = new DevicePageViewModel(deviceViewModel, registry);
_mainPage = new DevicePageViewModel(deviceViewModel, registry, remoteNavigator);
CurrentSubpage = _mainPage;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ public class DeviceViewModel : ViewModelBase, IDisposable

public DeviceRootPageViewModel RootPageViewModel { get; }

public DeviceViewModel(RemoteDevice device, IUIBindingsRegistry bindingsRegistry, IUISlotRegistry slotRegistry)
public DeviceViewModel(RemoteDevice device, IUIBindingsRegistry bindingsRegistry, IUISlotRegistry slotRegistry, IRemoteRootPageDeviceNavigatorProxy remoteNavigator)
{
Device = device;
RootPageViewModel = new DeviceRootPageViewModel(this, slotRegistry);
RootPageViewModel = new DeviceRootPageViewModel(this, slotRegistry, remoteNavigator);

bindingsRegistry.RegisterDict(Device.States).AddTo(_disposables);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<Image asyncImageLoader:ImageLoader.Source="{Binding Info.Branding.LogoUrl}" Width="64" Height="64" />
</Border>
<StackPanel VerticalAlignment="Center" Spacing="5">
<TextBlock Text="{Binding Info.DisplayName}" TextWrapping="Wrap" FontSize="20" FontWeight="Bold" IsVisible="{Binding Info.DisplayName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Text="{Binding DisplayName}" TextWrapping="Wrap" FontSize="20" FontWeight="Bold" IsVisible="{Binding DisplayName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock properties:TextBlockTextComponentProperty.TextComponent="{Binding Info.Description}" TextWrapping="Wrap" IsVisible="{Binding Info.Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</StackPanel>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
using System.Collections.Generic;
using System.Linq;
using R3;
using UniScan.Client.App.Views.ViewModel;
using UniScan.Network.Data.Info.Remote;

namespace UniScan.Client.App.Views.Remote;

public class RemoteInfoControlViewModel(RemoteInfo info) : ViewModelBase
//TODO this is done in a stupid way but rushing to get this done asap
public class RemoteInfoControlViewModel(RemoteInfo info, string fallbackDisplayName) : ViewModelBase
{
public RemoteInfo Info { get; } = info;

public string DisplayName => !string.IsNullOrWhiteSpace(Info.DisplayName) ? Info.DisplayName : fallbackDisplayName;

public List<RemoteLinkControlViewModel> Links { get; set; } =
[.. info.Branding.Links.Select(l => new RemoteLinkControlViewModel(l))];
[.. info.Branding.Links.Where(l => l != default).Select(l => new RemoteLinkControlViewModel(l))];
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:remote="clr-namespace:UniScan.Client.App.Views.Remote"
xmlns:properties="clr-namespace:UniScan.Client.App.UI.Properties"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="UniScan.Client.App.Views.Remote.RemoteLinkControl"
x:DataType="remote:RemoteLinkControlViewModel">
Expand All @@ -12,7 +13,7 @@
<Border IsVisible="{Binding Link.IconUrl, Converter={x:Static ObjectConverters.IsNotNull}}" CornerRadius="10" VerticalAlignment="Center">
<Image asyncImageLoader:ImageLoader.Source="{Binding Link.IconUrl}" Width="32" Height="32" />
</Border>
<TextBlock Text="{Binding Link.Name}" VerticalAlignment="Center" />
<TextBlock properties:TextBlockTextComponentProperty.TextComponent="{Binding Link.Name}" VerticalAlignment="Center" />
</StackPanel>
</HyperlinkButton>
</UserControl>
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
using System.Net.Mime;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using ObservableCollections;
using R3;
using Serilog;
using Shiki.Common.Identity.Slug;
using Shiki.Common.Identity.Slug.Formatting.Formatters;
using Shiki.Internationalization;
using Shiki.Internationalization.Component;
using UniScan.Client.App.UI.ServersideRendering;
using UniScan.Client.App.Views.Global;
Expand All @@ -35,17 +35,17 @@ public class RemoteRootPageViewModel : SubPagedViewModelBase, IDisposable, IRemo
private RemotePageViewModel? _mainPage;

private readonly CompositeDisposable _disposables = new();

private readonly TextComponentResolverService _textComponentResolver;

public RemoteRootPageViewModel(RemoteViewModel remoteViewModel, TextComponentResolverService resolver) : base(new NotConnectedRemotePageViewModel(remoteViewModel), UniScanApp.Identifier.Derived("view_model", "remote", new Slug<SnakeSlugFormatter>(remoteViewModel.Remote.Id.ToString())))
public IRelayCommand NavigateCommand { get; }

public RemoteRootPageViewModel(RemoteViewModel remoteViewModel) : base(new NotConnectedRemotePageViewModel(remoteViewModel), UniScanApp.Identifier.Derived("view_model", "remote", new Slug<SnakeSlugFormatter>(remoteViewModel.Remote.Id.ToString())))
{
_textComponentResolver = resolver;
_notConnectedPage = (NotConnectedRemotePageViewModel)CurrentSubpage;

RemoteViewModel = remoteViewModel;

RemoteViewModel.Remote.ConnectionStatus.AsObservable().Skip(1).Subscribe(OnConnectionStateChanged).AddTo(_disposables);
NavigateCommand = new RelayCommand<RemoteDevice?>(((IRemoteRootPageDeviceNavigatorProxy)this).Navigate);
}

private void TearDown()
Expand All @@ -60,8 +60,14 @@ public void Dispose()
TearDown();
}

void IRemoteRootPageDeviceNavigatorProxy.Navigate(RemoteDevice device)
void IRemoteRootPageDeviceNavigatorProxy.Navigate(RemoteDevice? device)
{
if (device == null)
{
Dispatcher.UIThread.Post(() => { CurrentSubpage = (ObservableObject?)_mainPage ?? _notConnectedPage; });
return;
}

DeviceViewModel? p = RemoteViewModel.DevicesView.FirstOrDefault(page => page.Device.Identifier == device.Identifier);
if (p != null)
{
Expand Down Expand Up @@ -139,5 +145,7 @@ private void OnConnectionStateChanged(IConnectionStatusContext ctx)

public interface IRemoteRootPageDeviceNavigatorProxy
{
void Navigate(RemoteDevice device);
IRelayCommand NavigateCommand { get; }

void Navigate(RemoteDevice? device);
}
9 changes: 4 additions & 5 deletions src/Client/UniScan.Client.App/Views/Remote/RemoteViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
using R3;
using Shiki.Common.Identity.Slug;
using Shiki.Common.Identity.Slug.Formatting.Formatters;
using Shiki.Internationalization;
using Shiki.Internationalization.Component;
using UniScan.Client.App.Core.Helpers;
using UniScan.Client.App.UI.ServersideRendering;
Expand Down Expand Up @@ -53,17 +52,17 @@ public RemoteViewModel(RemoteServer remote, IServiceProvider provider)
Pipeline = new RemoteConnectionPipeline();
_serviceProvider = provider;

RootPageViewModel = new RemoteRootPageViewModel(this, provider.GetRequiredService<TextComponentResolverService>());
RootPageViewModel = new RemoteRootPageViewModel(this);

IUIBindingsRegistry bindingsRegistry = provider.GetRequiredService<IUIBindingsRegistry>();
IUISlotRegistry slotRegistry = provider.GetRequiredService<IUISlotRegistry>();

_deviceList = new SynchronizedViewMirror<KeyValuePair<Slug<SnakeSlugFormatter>, RemoteDevice>, DeviceViewModel>(Remote.Devices.CreateView(kvp => new DeviceViewModel(kvp.Value, bindingsRegistry, slotRegistry)));
_deviceList = new SynchronizedViewMirror<KeyValuePair<Slug<SnakeSlugFormatter>, RemoteDevice>, DeviceViewModel>(Remote.Devices.CreateView(kvp => new DeviceViewModel(kvp.Value, bindingsRegistry, slotRegistry, RootPageViewModel)));
DevicesView = _deviceList.Output;

Remote.RemoteInfo.AsObservable().Subscribe((v) =>
Remote.RemoteInfo.AsObservable().Subscribe(v =>
{
RemoteInfoControlViewModel? n = v != null ? new RemoteInfoControlViewModel(v) : null;
RemoteInfoControlViewModel? n = v != null ? new RemoteInfoControlViewModel(v, Remote.DisplayName.Value) : null;

InfoViewModelStream.OnNext(n);
OnPropertyChanged(nameof(InfoViewModel));
Expand Down
Loading
Loading