Compare commits
2 commits
Author | SHA1 | Date | |
---|---|---|---|
31fe6ba022 | |||
4cf9a2a58f |
4 changed files with 82 additions and 18 deletions
32
README.md
32
README.md
|
@ -1,18 +1,36 @@
|
||||||
# FCaster
|
# FCaster
|
||||||
|
|
||||||
FCaster is a native cross-platform application to cast media to a [FCast](https://fcast.org) Receiver, built with [Fyne](https://fyne.io).
|
FCaster is a Go [library](https://pkg.go.dev/git.massive.box/massivebox/fcaster/fcast) implementing a [FCast](https://fcast.org) Sender, which allows to send media to a Receiver. It also has a simple cross-platform application, built with [Fyne](https://fyne.io), to showcase its features.
|
||||||
The protocol logic is all packaged into the [FCast Library](https://pkg.go.dev/git.massive.box/massivebox/fcaster/fcast), which you can use in your own projects to build a FCast Sender!
|
|
||||||
|
|
||||||
Currently, the app only allows you to stream from URLs, but I will add a way to stream local files and Jellyfin media directly. (You can already stream Jellyfin media by copying the stream URL from Jellyfin and pasting it in the app - [detailed instructions](https://s.massive.box/fcaster-jellyfin)).
|
**Notice**: FCaster is NOT associated to, or endorsed by, FUTO. It's an unofficial implementation of the FCast Protocol.
|
||||||
|
|
||||||
If you're on Android, I recommend you [disable battery optimizations](https://support.google.com/pixelphone/thread/299966895/turn-off-battery-optimization-for-an-app?hl=en) for FCaster, otherwise the app will disconnect when your device locks.
|
## Library
|
||||||
|
|
||||||
## Screenshots
|
The [FCast library](https://pkg.go.dev/git.massive.box/massivebox/fcaster/fcast) allows you to stream media to a FCast Receiver from your own software!
|
||||||
|
|
||||||
|
| Feature | Supported |
|
||||||
|
|-------------------|-----------|
|
||||||
|
| TCP | ✅ |
|
||||||
|
| WebSockets | ❌ |
|
||||||
|
| mDNS Discovery | ✅ |
|
||||||
|
| Outgoing Commands | ✅ |
|
||||||
|
| Incoming Commands | ✅ |
|
||||||
|
| QR Code | ❌ |
|
||||||
|
|
||||||
|
Since the library is in active development, its stability is not guaranteed.
|
||||||
|
|
||||||
|
## App
|
||||||
|
|
||||||
|
Due to the limitations of Fyne on Android (not implementing [foreground services](https://developer.android.com/guide/components/foreground-services)), the FCaster App will not support features such as local media casting in the near future. We are working on an alternative implementation.
|
||||||
|
|
||||||
|
Currently, the app only allows you to stream from URLs. (You can already stream Jellyfin media by copying the stream URL from Jellyfin and pasting it in the app - [detailed instructions](https://s.massive.box/fcaster-jellyfin)).
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|  |  |
|
|  |  |
|
||||||
| --------------------------------- |-----------------------------------|
|
| - | - |
|
||||||
|
|
||||||
## Download
|
### Download
|
||||||
|
|
||||||
Head over to the [Releases](https://git.massive.box/massivebox/fcaster/releases) page to find builds for your device. You can use [Obtanium](https://obtainium.imranr.dev/) to get automatic updates on Android.
|
Head over to the [Releases](https://git.massive.box/massivebox/fcaster/releases) page to find builds for your device. You can use [Obtanium](https://obtainium.imranr.dev/) to get automatic updates on Android.
|
||||||
|
|
||||||
|
|
|
@ -39,16 +39,10 @@ func (c *Controller) StartCasting(selected string, mediaURL string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.Model.ConnectToDevice(selected)
|
|
||||||
if err != nil {
|
|
||||||
c.LogAndShowError(fmt.Errorf("error connecting to device %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
go func() {
|
||||||
err = c.Model.StartCast(mediaURL)
|
err := c.Model.Cast(selected, mediaURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.LogAndShowError(fmt.Errorf("error starting cast %v", err))
|
c.LogAndShowError(fmt.Errorf("error starting cast: %v", err))
|
||||||
c.DiscoverDevices()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
@ -56,6 +50,10 @@ func (c *Controller) StartCasting(selected string, mediaURL string) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Controller) ShowReconnecting(reconnecting bool) {
|
||||||
|
c.View.PopupReconnecting(reconnecting)
|
||||||
|
}
|
||||||
|
|
||||||
// Note: these are app codes, they are NOT related to fcast opcodes!
|
// Note: these are app codes, they are NOT related to fcast opcodes!
|
||||||
const (
|
const (
|
||||||
ActionPlay = 0
|
ActionPlay = 0
|
||||||
|
@ -78,7 +76,6 @@ func (c *Controller) PlayerAction(action int, args ...float32) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) ExitCasting() {
|
func (c *Controller) ExitCasting() {
|
||||||
c.PlayerAction(ActionStop)
|
|
||||||
c.DiscoverDevices()
|
c.DiscoverDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
32
app/model.go
32
app/model.go
|
@ -7,6 +7,8 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
|
@ -14,9 +16,11 @@ type Model struct {
|
||||||
DiscoveredDevices []fcast.DiscoveredHost
|
DiscoveredDevices []fcast.DiscoveredHost
|
||||||
Connection *fcast.Connection
|
Connection *fcast.Connection
|
||||||
EventManager *fcast.EventManager
|
EventManager *fcast.EventManager
|
||||||
|
DeviceName string
|
||||||
Time float32
|
Time float32
|
||||||
Length float32
|
Length float32
|
||||||
Volume float32
|
Volume float32
|
||||||
|
IsReconnecting bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) DiscoverDevices() ([]string, error) {
|
func (m *Model) DiscoverDevices() ([]string, error) {
|
||||||
|
@ -47,10 +51,29 @@ func (m *Model) ConnectToDevice(name string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.Connection = connection
|
m.Connection = connection
|
||||||
|
m.DeviceName = name
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) StartCast(mediaURL string) error {
|
func (m *Model) Cast(selectedDevice, mediaURL string) error {
|
||||||
|
err := m.doCast(selectedDevice, mediaURL)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) || strings.Contains(err.Error(), "software caused connection abort") {
|
||||||
|
m.Controller.ShowReconnecting(true)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
return m.Cast(selectedDevice, mediaURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.Controller.ShowReconnecting(false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) doCast(selectedDevice, mediaURL string) error {
|
||||||
|
|
||||||
|
err := m.ConnectToDevice(selectedDevice)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error connecting to device %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
m.EventManager = &fcast.EventManager{}
|
m.EventManager = &fcast.EventManager{}
|
||||||
|
|
||||||
|
@ -81,6 +104,13 @@ func (m *Model) StartCast(mediaURL string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
m.Volume = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.Connection.SendMessage(&fcast.PingMessage{})
|
||||||
|
if err == nil {
|
||||||
|
m.Controller.ShowReconnecting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.Connection.ListenForMessages(m.EventManager)
|
return m.Connection.ListenForMessages(m.EventManager)
|
||||||
|
|
19
app/view.go
19
app/view.go
|
@ -18,10 +18,29 @@ type View struct {
|
||||||
Controller *Controller
|
Controller *Controller
|
||||||
Window fyne.Window
|
Window fyne.Window
|
||||||
CastingScreenElements CastingScreenElements
|
CastingScreenElements CastingScreenElements
|
||||||
|
ReconnectingDialog *dialog.CustomDialog
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *View) PopupError(err error) {
|
func (v *View) PopupError(err error) {
|
||||||
|
fyne.Do(func() {
|
||||||
dialog.ShowError(err, v.Window)
|
dialog.ShowError(err, v.Window)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *View) PopupReconnecting(show bool) {
|
||||||
|
if v.ReconnectingDialog != nil && !show {
|
||||||
|
fyne.DoAndWait(func() {
|
||||||
|
v.ReconnectingDialog.Dismiss()
|
||||||
|
v.ReconnectingDialog = nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.ReconnectingDialog == nil && show {
|
||||||
|
v.ReconnectingDialog = dialog.NewCustomWithoutButtons("Reconnecting to stream...", widget.NewProgressBarInfinite(), v.Window)
|
||||||
|
fyne.Do(func() {
|
||||||
|
v.ReconnectingDialog.Show()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewView(controller *Controller) *View {
|
func NewView(controller *Controller) *View {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue