Initial upload

This commit is contained in:
MassiveBox 2025-05-29 22:52:44 +02:00
commit cefd7abe8a
Signed by: massivebox
GPG key ID: 9B74D3A59181947D
19 changed files with 1027 additions and 0 deletions

8
app/FyneApp.toml Normal file
View file

@ -0,0 +1,8 @@
Website = "https://git.massive.box/massivebox/fincaster"
[Details]
Icon = "Icon.png"
Name = "FCaster"
ID = "box.massive.fcaster"
Version = "0.1.0"
Build = 3

BIN
app/Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

87
app/controller.go Normal file
View file

@ -0,0 +1,87 @@
package main
import (
"errors"
"fmt"
"log"
)
type Controller struct {
Model *Model
View *View
}
func (c *Controller) LogAndShowError(err error) {
log.Printf("[ERR ] app: %s", err.Error())
c.View.PopupError(err)
}
func (c *Controller) DiscoverDevices() {
go func() {
devices, err := c.Model.DiscoverDevices()
if err != nil {
c.LogAndShowError(fmt.Errorf("error discovering devices %v", err))
return
}
if len(devices) == 0 {
c.LogAndShowError(errors.New("no devices found"))
return
}
c.View.MainScreen(devices)
}()
c.View.LoadingScreen()
}
func (c *Controller) StartCasting(selected string, mediaURL string) {
if selected == "" {
c.LogAndShowError(errors.New("no device selected"))
return
}
err := c.Model.ConnectToDevice(selected)
if err != nil {
c.LogAndShowError(fmt.Errorf("error connecting to device %v", err))
return
}
go func() {
err = c.Model.StartCast(mediaURL)
if err != nil {
c.LogAndShowError(fmt.Errorf("error starting cast %v", err))
c.DiscoverDevices()
return
}
}()
c.View.CastingScreen(selected)
}
// Note: these are app codes, they are NOT related to fcast opcodes!
const (
ActionPlay = 0
ActionPause = 1
ActionSkipBack = 2
ActionSkipForward = 3
ActionStop = 4
ActionVolumeUp = 5
ActionVolumeDown = 6
ArgsActionSeek = 7 // seekTime
RespPlaybackUpdate = 0 // videoRuntime, currentTime
)
func (c *Controller) PlayerAction(action int, args ...float32) {
err := c.Model.PlayerAction(action, args...)
if err != nil {
c.LogAndShowError(fmt.Errorf("error performing action %v", err))
}
}
func (c *Controller) ExitCasting() {
c.PlayerAction(ActionStop)
c.DiscoverDevices()
}
func (c *Controller) ReceiverResponse(action int, args ...float32) {
c.View.UpdateCastingScreen(action, args...)
}

15
app/main.go Normal file
View file

@ -0,0 +1,15 @@
package main
func main() {
m := &Model{}
c := &Controller{Model: m}
v := NewView(c)
m.Controller = c
c.View = v
c.DiscoverDevices()
c.View.Start()
}

156
app/model.go Normal file
View file

@ -0,0 +1,156 @@
package main
import (
"errors"
"fmt"
"git.massive.box/massivebox/fcaster/fcast"
"io"
"math"
"net/http"
)
type Model struct {
Controller *Controller
DiscoveredDevices []fcast.DiscoveredHost
Connection *fcast.Connection
EventManager *fcast.EventManager
Time float32
Length float32
Volume float32
}
func (m *Model) DiscoverDevices() ([]string, error) {
devices, err := fcast.Discover()
if err != nil {
return nil, err
}
m.DiscoveredDevices = devices
var names []string
for _, d := range m.DiscoveredDevices {
names = append(names, d.Name)
}
return names, nil
}
func (m *Model) ConnectToDevice(name string) error {
device := &fcast.DiscoveredHost{}
for _, d := range m.DiscoveredDevices {
if d.Name == name {
device = &d
}
}
if device.Name == "" {
return errors.New("device not found")
}
connection, err := fcast.Connect(device.IPv4)
if err != nil {
return err
}
m.Connection = connection
return nil
}
func (m *Model) StartCast(mediaURL string) error {
m.EventManager = &fcast.EventManager{}
m.EventManager.SetHandler(fcast.PlaybackUpdateMessage{}, func(message fcast.Message) {
msg := message.(*fcast.PlaybackUpdateMessage)
m.Length = msg.Duration
m.Time = msg.Time
m.Controller.ReceiverResponse(RespPlaybackUpdate, msg.Duration, msg.Time)
})
m.EventManager.SetHandler(fcast.VolumeUpdateMessage{}, func(message fcast.Message) {
msg := message.(*fcast.VolumeUpdateMessage)
m.Volume = msg.Volume
})
m.EventManager.SetHandler(fcast.PlaybackErrorMessage{}, func(message fcast.Message) {
msg := message.(*fcast.PlaybackErrorMessage)
m.Controller.LogAndShowError(fmt.Errorf("playback error: %s", msg.Message))
})
if mediaURL != "" {
mediaType, err := getMediaType(mediaURL)
if err != nil {
return err
}
err = m.Connection.SendMessage(&fcast.PlayMessage{
Container: mediaType,
Url: mediaURL,
})
if err != nil {
return err
}
}
return m.Connection.ListenForMessages(m.EventManager)
}
func (m *Model) PlayerAction(action int, args ...float32) error {
var message fcast.Message
switch action {
case ActionPlay:
message = &fcast.ResumeMessage{}
break
case ActionPause:
message = &fcast.PauseMessage{}
break
case ActionSkipBack:
message = &fcast.SeekMessage{Time: int(m.Time - 10)}
break
case ActionSkipForward:
message = &fcast.SeekMessage{Time: int(m.Time + 10)}
break
case ActionStop:
message = &fcast.StopMessage{}
break
case ActionVolumeUp:
message = &fcast.SetVolumeMessage{Volume: m.Volume + 0.1}
break
case ActionVolumeDown:
message = &fcast.SetVolumeMessage{Volume: m.Volume - 0.1}
break
case ArgsActionSeek:
if math.Abs(float64(args[0]-m.Time)) < 2 {
return nil
}
message = &fcast.SeekMessage{Time: int(args[0])}
break
}
return m.Connection.SendMessage(message)
}
func getMediaType(url string) (string, error) {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("Range", "bytes=0-512")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return "", fmt.Errorf("server returned unexpected status: %s", resp.Status)
}
buffer := make([]byte, 512)
n, err := resp.Body.Read(buffer)
if err != nil && err != io.EOF {
return "", err
}
mimeType := http.DetectContentType(buffer[:n])
return mimeType, nil
}

141
app/view.go Normal file
View file

@ -0,0 +1,141 @@
package main
import (
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
type CastingScreenElements struct {
TimestampLabel, ProgressBar, DurationLabel fyne.CanvasObject
}
type View struct {
Controller *Controller
Window fyne.Window
CastingScreenElements CastingScreenElements
}
func (v *View) PopupError(err error) {
dialog.ShowError(err, v.Window)
}
func NewView(controller *Controller) *View {
v := &View{
Controller: controller,
Window: app.New().NewWindow("FCaster"),
}
v.Window.Resize(fyne.NewSize(300, 300))
return v
}
func (v *View) Start() {
v.Window.ShowAndRun()
}
func (v *View) LoadingScreen() {
loading := container.NewVBox(
widget.NewLabel("Discovering devices..."),
widget.NewProgressBarInfinite(),
widget.NewButton("Refresh", func() { v.Controller.DiscoverDevices() }),
)
fyne.Do(func() {
v.Window.SetContent(loading)
})
}
func (v *View) MainScreen(deviceNames []string) {
selector := widget.NewSelect(deviceNames, nil)
reloadBtn := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() {
v.Controller.DiscoverDevices()
})
entry := widget.NewEntry()
fyne.Do(func() {
v.Window.SetContent(container.NewVBox(
widget.NewLabel("Select the device:"),
container.NewBorder(
nil, nil, nil,
container.NewVBox(reloadBtn),
selector,
),
widget.NewLabel("Enter the media URL:"),
entry,
widget.NewButton("Start Casting", func() {
v.Controller.StartCasting(selector.Selected, entry.Text)
}),
widget.NewButton("Join Existing Casting", func() {
v.Controller.StartCasting(selector.Selected, "")
}),
))
})
}
func (v *View) CastingScreen(deviceName string) {
v.CastingScreenElements = CastingScreenElements{
TimestampLabel: widget.NewLabel("00:00"),
DurationLabel: widget.NewLabel("00:00"),
ProgressBar: widget.NewSlider(0, 100),
}
el := v.CastingScreenElements
el.ProgressBar.(*widget.Slider).OnChanged = func(f float64) {
fyne.Do(func() {
el.TimestampLabel.(*widget.Label).SetText(formatTime(int(f)))
})
}
el.ProgressBar.(*widget.Slider).OnChangeEnded = func(f float64) {
v.Controller.PlayerAction(ArgsActionSeek, float32(f))
}
v.Window.SetContent(container.NewVBox(
widget.NewLabel(deviceName),
container.NewBorder(
nil, nil, el.TimestampLabel, el.DurationLabel,
el.ProgressBar,
),
widget.NewButton("Exit", func() { v.Controller.ExitCasting() }),
container.NewHBox(
widget.NewButtonWithIcon("", theme.MediaPlayIcon(), func() { v.Controller.PlayerAction(ActionPlay) }),
widget.NewButtonWithIcon("", theme.MediaPauseIcon(), func() { v.Controller.PlayerAction(ActionPause) }),
widget.NewButtonWithIcon("", theme.MediaFastRewindIcon(), func() { v.Controller.PlayerAction(ActionSkipBack) }),
widget.NewButtonWithIcon("", theme.MediaFastForwardIcon(), func() { v.Controller.PlayerAction(ActionSkipForward) }),
widget.NewButtonWithIcon("", theme.MediaStopIcon(), func() { v.Controller.PlayerAction(ActionStop) }),
widget.NewButtonWithIcon("", theme.VolumeDownIcon(), func() { v.Controller.PlayerAction(ActionVolumeDown) }),
widget.NewButtonWithIcon("", theme.VolumeUpIcon(), func() { v.Controller.PlayerAction(ActionVolumeUp) }),
),
))
}
func (v *View) UpdateCastingScreen(action int, args ...float32) {
fyne.Do(func() {
switch action {
case RespPlaybackUpdate:
v.CastingScreenElements.ProgressBar.(*widget.Slider).Max = float64(args[0])
v.CastingScreenElements.ProgressBar.(*widget.Slider).SetValue(float64(args[1]))
// timestamp label is updated from ProgressBar OnChange listener
v.CastingScreenElements.DurationLabel.(*widget.Label).SetText(formatTime(int(args[0])))
return
}
})
}
func formatTime(seconds int) string {
hours := seconds / 3600
minutes := (seconds % 3600) / 60
sec := seconds % 60
if hours == 0 {
return fmt.Sprintf("%02d:%02d", minutes, sec)
}
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, sec)
}