Skip to content

Commit f0bd906

Browse files
authored
Merge pull request #149 from alexballas/devel
v2.3.0
2 parents 7c41db1 + bae8f65 commit f0bd906

78 files changed

Lines changed: 6206 additions & 1138 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ When transcoding is enabled, Go2TV probes available GPU H.264 encoders first and
7272

7373
---
7474

75+
## Playlist (GUI)
76+
77+
Go2TV GUI keeps the current selection in a **Playlist**, even for a single file.
78+
79+
- Selecting or dropping a single local file creates a 1-item playlist
80+
- Selecting or dropping multiple files creates a multi-item playlist
81+
- The **Playlist** window lets you add, remove, reorder, and select items
82+
- Drag and drop on the main window replaces the current playlist
83+
- Drag and drop on the Playlist window appends files when a playlist already exists
84+
- **Next**, **Previous**, and **Auto-Play Next File** follow the playlist order
85+
- **Auto-Play Next File** wraps to the start of the playlist when it reaches the end
86+
- **Same File Types Only** is still respected for auto-play traversal
87+
88+
---
89+
7590
## RTMP Streaming (Chromecast only)
7691

7792
Go2TV can act as an RTMP server, allowing you to stream from OBS or other software directly to your Chromecast. **This feature requires FFmpeg.**
@@ -158,7 +173,8 @@ yt-dlp -o - "https://youtu.be/..." | go2tv -tc -t http://192.168.1.50:8009
158173
- **Transcoding** - Converts incompatible video formats on-the-fly (requires FFmpeg)
159174
- **Subtitles** - Supports external SRT/VTT files and embedded MKV subtitles
160175
- **Seek support** - Jump to any position in the video
161-
- **Loop and auto-play** - Loop a single file or auto-play the next file in folder
176+
- **Playlist playback** - Single-file and multi-file playlists with add/remove/reorder/select support
177+
- **Loop and auto-play** - Loop the current file or auto-play through the playlist
162178
- **Gapless playback** - Supported for DLNA devices
163179
- **RTMP Server** - Cast live streams from OBS directly to Chromecast (requires FFmpeg)
164180
- **Cast Desktop (experimental)** - Cast desktop as live stream to Chromecast (requires FFmpeg)
@@ -190,18 +206,13 @@ If you want tool-driven casting from MCP-compatible workflows, use mcp-beam.
190206

191207
## Notes
192208

193-
**Firewall Configuration**
194-
195-
Go2TV uses ports 3339-3438 for device discovery. If you're behind a firewall, allow inbound UDP traffic on these ports.
209+
**Chromecast receiver**
196210

197-
**macOS Security**
211+
Go2TV uses a custom Chromecast receiver hosted at https://cast-receiver.go2tv.app/. It is not part of this open-source repository and is not currently published. Functionality matches the default receiver, with minor branding differences.
198212

199-
If you see "cannot be opened because the developer cannot be verified":
200-
1. Control-click the app, then choose Open from the menu
201-
2. Click Open
213+
**Firewall Configuration**
202214

203-
If you see "go2tv is damaged and can't be opened":
204-
- Run: `xattr -cr /path/to/go2tv.app`
215+
Go2TV uses ports 3339-3438 for device discovery. If you're behind a firewall, allow inbound UDP traffic on these ports.
205216

206217
---
207218

assets/linux/app.go2tv.go2tv.appdata.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@
5151
</branding>
5252

5353
<releases>
54+
<release version="2.3.0" date="2026-04-21" type="stable">
55+
<description>
56+
<ul>
57+
<li>Added resume playback support to continue media from the last saved position.</li>
58+
<li>Added a media queue to organize and play files in sequence.</li>
59+
<li>Polished the GUI with device badges, layout tuning, and small mobile cleanups.</li>
60+
<li>Added better exportable diagnostics and crash reports.</li>
61+
<li>Improved device discovery.</li>
62+
</ul>
63+
</description>
64+
<url type="details">https://github.com/alexballas/go2tv/releases/tag/v2.3.0</url>
65+
</release>
5466
<release version="2.2.0" date="2026-03-14" type="stable">
5567
<description>
5668
<ul>

castprotocol/client.go

Lines changed: 133 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"io"
88
"net"
99
"net/url"
10+
"path"
11+
"path/filepath"
12+
"strings"
1013
"sync"
1114
"time"
1215

@@ -109,13 +112,122 @@ func isTimeoutError(err error) bool {
109112
return false
110113
}
111114

115+
func (c *CastClient) defaultReceiverReady() bool {
116+
app := c.app.App()
117+
return app != nil && app.AppId == cast.DefaultMediaReceiverAppID && app.TransportId != ""
118+
}
119+
120+
func normalizeMediaTitle(title string, mediaURL string) string {
121+
if normalized := deriveMediaTitle(title); normalized != "" {
122+
return normalized
123+
}
124+
return deriveMediaTitle(mediaURL)
125+
}
126+
127+
func deriveMediaTitle(value string) string {
128+
value = strings.TrimSpace(value)
129+
if value == "" {
130+
return ""
131+
}
132+
133+
if u, err := url.Parse(value); err == nil && u.Scheme != "" {
134+
if base := path.Base(u.Path); base != "" && base != "." && base != "/" {
135+
return base
136+
}
137+
if host := strings.TrimSpace(u.Host); host != "" {
138+
return host
139+
}
140+
}
141+
142+
if base := filepath.Base(value); base != "" && base != "." && base != string(filepath.Separator) {
143+
return base
144+
}
145+
146+
return value
147+
}
148+
149+
// Ensure the Default Media Receiver is running before custom media commands.
150+
func (c *CastClient) ensureDefaultReceiverReady() error {
151+
if c.defaultReceiverReady() {
152+
return nil
153+
}
154+
155+
if err := c.app.Update(); err == nil && c.defaultReceiverReady() {
156+
return nil
157+
}
158+
159+
var lastErr error
160+
for attempt := range 5 {
161+
if !c.IsConnected() {
162+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Msg("connection closed during receiver launch, aborting silently")
163+
return nil
164+
}
165+
166+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Int("Attempt", attempt+1).Msg("launching default receiver")
167+
if err := LaunchDefaultReceiver(c.conn); err != nil {
168+
lastErr = err
169+
if isTimeoutError(err) && attempt < 4 {
170+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Int("Attempt", attempt+1).Err(err).Msg("timeout, TV may be waking up, retrying...")
171+
if !c.IsConnected() {
172+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Msg("connection closed during retry wait, aborting silently")
173+
return nil
174+
}
175+
time.Sleep(4 * time.Second)
176+
continue
177+
}
178+
c.Log().Error().Str("Method", "ensureDefaultReceiverReady").Err(err).Msg("launch receiver failed")
179+
return fmt.Errorf("launch receiver: %w", err)
180+
}
181+
182+
for i := range 8 {
183+
if !c.IsConnected() {
184+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Msg("connection closed during app update, aborting silently")
185+
return nil
186+
}
187+
188+
if err := c.app.Update(); err != nil {
189+
lastErr = err
190+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Int("Attempt", i+1).Err(err).Msg("app.Update retry")
191+
time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
192+
continue
193+
}
194+
195+
if c.defaultReceiverReady() {
196+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Str("TransportId", c.app.App().TransportId).Msg("default receiver ready")
197+
return nil
198+
}
199+
200+
lastErr = fmt.Errorf("failed to get default receiver transport ID after retries")
201+
time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
202+
}
203+
204+
if attempt < 4 {
205+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Int("Attempt", attempt+1).Msg("receiver not ready, retrying...")
206+
if !c.IsConnected() {
207+
c.Log().Debug().Str("Method", "ensureDefaultReceiverReady").Msg("connection closed during retry wait, aborting silently")
208+
return nil
209+
}
210+
time.Sleep(4 * time.Second)
211+
continue
212+
}
213+
}
214+
215+
if lastErr == nil {
216+
lastErr = fmt.Errorf("failed to get default receiver transport ID after retries")
217+
}
218+
219+
c.Log().Error().Str("Method", "ensureDefaultReceiverReady").Err(lastErr).Msg("failed to launch default receiver")
220+
return lastErr
221+
}
222+
112223
// Load loads media from URL onto the Chromecast.
113224
// startTime is the position in seconds to start playback from.
114225
// duration is the total media duration in seconds (0 to let Chromecast detect).
115226
// If subtitleURL is provided, uses custom load command with subtitle tracks.
116227
// If live is true, uses StreamType "LIVE" to identify as live stream.
117-
func (c *CastClient) Load(mediaURL string, contentType string, startTime int, duration float64, subtitleURL string, live bool) error {
118-
c.Log().Debug().Str("Method", "Load").Str("URL", mediaURL).Str("ContentType", contentType).Int("StartTime", startTime).Float64("Duration", duration).Bool("HasSubs", subtitleURL != "").Bool("Live", live).Msg("loading media")
228+
func (c *CastClient) Load(mediaURL string, contentType string, title string, startTime int, duration float64, subtitleURL string, live bool) error {
229+
mediaTitle := normalizeMediaTitle(title, mediaURL)
230+
c.Log().Debug().Str("Method", "Load").Str("URL", mediaURL).Str("ContentType", contentType).Str("Title", mediaTitle).Int("StartTime", startTime).Float64("Duration", duration).Bool("HasSubs", subtitleURL != "").Bool("Live", live).Msg("loading media")
119231

120232
// Check if connection is still active, reconnect if needed
121233
// This handles cases where Close() was called but the client is being reused
@@ -126,10 +238,10 @@ func (c *CastClient) Load(mediaURL string, contentType string, startTime int, du
126238
}
127239
}
128240

129-
// If no subtitles, no custom duration, and NOT a live stream: use standard app.Load()
241+
// If no subtitles, no custom duration, no title, and NOT a live stream: use standard app.Load()
130242
// For live streams, we MUST use custom LoadWithSubtitles to set StreamType "LIVE"
131243
// (go-chromecast library hardcodes StreamType "BUFFERED" so we need custom path for LIVE)
132-
if subtitleURL == "" && duration == 0 && !live {
244+
if subtitleURL == "" && duration == 0 && mediaTitle == "" && !live {
133245
// Retry loop for TV wake-up scenarios (timeout errors)
134246
var lastErr error
135247
for attempt := range 5 {
@@ -160,70 +272,42 @@ func (c *CastClient) Load(mediaURL string, contentType string, startTime int, du
160272
// With subtitles or custom duration: launch the app first WITHOUT loading media, then send custom load
161273
// This prevents double playback (first without subs, then with subs queued)
162274
// Retry loop for TV wake-up scenarios
275+
if err := c.ensureDefaultReceiverReady(); err != nil {
276+
return err
277+
}
278+
163279
var lastErr error
164280
for attempt := range 5 {
165281
if !c.IsConnected() {
166282
c.Log().Debug().Str("Method", "Load").Msg("connection closed during load, aborting silently")
167283
return nil
168284
}
169285

170-
c.Log().Debug().Str("Method", "Load").Int("Attempt", attempt).Msg("launching default receiver")
171-
if err := LaunchDefaultReceiver(c.conn); err != nil {
172-
lastErr = err
173-
if isTimeoutError(err) && attempt < 5 {
174-
c.Log().Debug().Str("Method", "Load").Int("Attempt", attempt).Err(err).Msg("timeout, TV may be waking up, retrying...")
175-
if !c.IsConnected() {
176-
c.Log().Debug().Str("Method", "Load").Msg("connection closed during retry wait, aborting silently")
177-
return nil
178-
}
179-
time.Sleep(4 * time.Second)
180-
continue
181-
}
182-
c.Log().Error().Str("Method", "Load").Err(err).Msg("launch receiver failed")
183-
return fmt.Errorf("launch receiver: %w", err)
184-
}
185-
186-
// Retry getting app state with backoff (handles "media receiver app not available")
187-
var transportId string
188-
for i := range 8 {
189-
if !c.IsConnected() {
190-
c.Log().Debug().Str("Method", "Load").Msg("connection closed during app update, aborting silently")
191-
return nil
192-
}
193-
194-
if err := c.app.Update(); err != nil {
195-
c.Log().Debug().Str("Method", "Load").Int("Attempt", i+1).Err(err).Msg("app.Update retry")
196-
time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
197-
continue
198-
}
199-
app := c.app.App()
200-
if app != nil && app.TransportId != "" {
201-
transportId = app.TransportId
202-
c.Log().Debug().Str("Method", "Load").Str("TransportId", transportId).Msg("got transport ID")
203-
break
204-
}
205-
time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
286+
app := c.app.App()
287+
transportId := ""
288+
if app != nil {
289+
transportId = app.TransportId
206290
}
207291

208292
if transportId == "" {
209-
lastErr = fmt.Errorf("failed to get transport ID after retries")
210-
if attempt < 5 {
211-
c.Log().Debug().Str("Method", "Load").Int("Attempt", attempt).Msg("no transport ID, TV may be waking up, retrying...")
293+
lastErr = fmt.Errorf("failed to get transport ID after receiver launch")
294+
if attempt < 4 {
295+
c.Log().Debug().Str("Method", "Load").Int("Attempt", attempt+1).Msg("no transport ID, retrying...")
212296
if !c.IsConnected() {
213297
c.Log().Debug().Str("Method", "Load").Msg("connection closed during retry wait, aborting silently")
214298
return nil
215299
}
216300
time.Sleep(4 * time.Second)
217301
continue
218302
}
219-
c.Log().Error().Str("Method", "Load").Msg("failed to get transport ID")
303+
c.Log().Error().Str("Method", "Load").Err(lastErr).Msg("failed to get transport ID")
220304
return lastErr
221305
}
222306

223307
// For live streams: load PAUSED then immediately send PLAY command
224308
// This simulates a "fast click" which avoids the 20-30s buffer that autoplay=true triggers
225309
autoplay := !live // Only autoplay if NOT a live stream
226-
err := LoadWithSubtitles(c.conn, transportId, mediaURL, contentType, startTime, duration, subtitleURL, live, autoplay)
310+
err := LoadWithSubtitles(c.conn, transportId, mediaURL, contentType, startTime, duration, subtitleURL, mediaTitle, live, autoplay)
227311
if err != nil {
228312
lastErr = err
229313
if isTimeoutError(err) && attempt < 5 {
@@ -278,8 +362,9 @@ func (c *CastClient) Load(mediaURL string, contentType string, startTime int, du
278362
// Unlike Load, this skips launching the receiver.
279363
// Use this when the receiver is already playing media and you want to load new content.
280364
// If live is true, uses StreamType "LIVE" to identify as live stream.
281-
func (c *CastClient) LoadOnExisting(mediaURL string, contentType string, startTime int, duration float64, subtitleURL string, live bool) error {
282-
c.Log().Debug().Str("Method", "LoadOnExisting").Str("URL", mediaURL).Str("ContentType", contentType).Int("StartTime", startTime).Float64("Duration", duration).Bool("HasSubs", subtitleURL != "").Bool("Live", live).Msg("loading media on existing receiver")
365+
func (c *CastClient) LoadOnExisting(mediaURL string, contentType string, title string, startTime int, duration float64, subtitleURL string, live bool) error {
366+
mediaTitle := normalizeMediaTitle(title, mediaURL)
367+
c.Log().Debug().Str("Method", "LoadOnExisting").Str("URL", mediaURL).Str("ContentType", contentType).Str("Title", mediaTitle).Int("StartTime", startTime).Float64("Duration", duration).Bool("HasSubs", subtitleURL != "").Bool("Live", live).Msg("loading media on existing receiver")
283368

284369
// LoadOnExisting requires an active connection (it's designed for already-running receivers)
285370
// Unlike Load(), we don't auto-reconnect because that would defeat the optimization purpose
@@ -310,7 +395,7 @@ func (c *CastClient) LoadOnExisting(mediaURL string, contentType string, startTi
310395
}
311396

312397
// For LoadOnExisting, always autoplay since it's for seek operations on active content
313-
err := LoadWithSubtitles(c.conn, transportId, mediaURL, contentType, startTime, duration, subtitleURL, live, true)
398+
err := LoadWithSubtitles(c.conn, transportId, mediaURL, contentType, startTime, duration, subtitleURL, mediaTitle, live, true)
314399
if err != nil {
315400
c.Log().Error().Str("Method", "LoadOnExisting").Err(err).Msg("failed")
316401
} else {

castprotocol/loader.go

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package castprotocol
22

33
import (
4-
"encoding/json"
54
"fmt"
65
"sync/atomic"
76

@@ -48,9 +47,10 @@ func (p *CustomLoadPayload) SetRequestId(id int) {
4847
// startTime: start position in seconds
4948
// duration: total media duration in seconds (0 to let Chromecast detect)
5049
// subtitleURL: URL of the WebVTT subtitle file (or empty for no subtitles)
50+
// title: media title shown by the receiver UI
5151
// live: if true, sets StreamType to "LIVE" to identify as live stream (DMR will show LIVE badge)
5252
// autoplay: if true, starts playback immediately; if false, waits for PLAY command
53-
func LoadWithSubtitles(conn cast.Conn, transportId string, mediaURL string, contentType string, startTime int, duration float64, subtitleURL string, live bool, autoplay bool) error {
53+
func LoadWithSubtitles(conn cast.Conn, transportId string, mediaURL string, contentType string, startTime int, duration float64, subtitleURL string, title string, live bool, autoplay bool) error {
5454
streamType := "BUFFERED"
5555
if live {
5656
streamType = "LIVE"
@@ -66,6 +66,12 @@ func LoadWithSubtitles(conn cast.Conn, transportId string, mediaURL string, cont
6666
if duration > 0 {
6767
media.Duration = float32(duration)
6868
}
69+
if title != "" {
70+
media.Metadata = &MediaMeta{
71+
MetadataType: 0,
72+
Title: title,
73+
}
74+
}
6975

7076
var activeTrackIds []int
7177

@@ -127,15 +133,12 @@ func (p *LaunchRequest) SetRequestId(id int) {
127133
p.RequestId = id
128134
}
129135

130-
// DefaultMediaReceiverAppID is the app ID for the Default Media Receiver
131-
const DefaultMediaReceiverAppID = "CC1AD845"
132-
133136
// LaunchDefaultReceiver launches the Default Media Receiver app without loading media.
134137
// This allows sending a LoadWithSubtitles command afterwards.
135138
func LaunchDefaultReceiver(conn cast.Conn) error {
136139
payload := &LaunchRequest{
137140
Type: "LAUNCH",
138-
AppId: DefaultMediaReceiverAppID,
141+
AppId: cast.DefaultMediaReceiverAppID,
139142
}
140143

141144
requestID := nextRequestID()
@@ -152,13 +155,3 @@ func LaunchDefaultReceiver(conn cast.Conn) error {
152155

153156
// CastNamespaceReceiver is the namespace for receiver control messages
154157
const CastNamespaceReceiver = "urn:x-cast:com.google.cast.receiver"
155-
156-
// MarshalJSON for custom JSON output
157-
func (m *MediaItemWithTracks) MarshalJSON() ([]byte, error) {
158-
type Alias MediaItemWithTracks
159-
return json.Marshal(&struct {
160-
*Alias
161-
}{
162-
Alias: (*Alias)(m),
163-
})
164-
}

0 commit comments

Comments
 (0)