Compare commits

..

2 commits
v0.1.0 ... main

Author SHA1 Message Date
31fe6ba022 Clarify project scope
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 15m15s
2025-06-16 10:38:51 +02:00
4cf9a2a58f
Improve casting resume behavior
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 15m49s
2025-06-07 22:43:22 +02:00
4 changed files with 82 additions and 18 deletions

View file

@ -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
| ![Main view](assets/mainView.png) | ![Cast view](assets/castView.png) | | ![Main view](assets/mainView.png) | ![Cast view](assets/castView.png) |
| --------------------------------- |-----------------------------------| | - | - |
## 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.

View file

@ -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()
} }

View file

@ -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)

View file

@ -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 {