@@ -103,6 +103,7 @@ type Options struct {
103103// process exits unexpectedly.
104104type Player struct {
105105 cmd * exec.Cmd
106+ exited * processWaiter
106107 ipc * ipcClient
107108 socketDir string
108109 events chan Event
@@ -123,6 +124,30 @@ type Player struct {
123124 lastCacheSec float64
124125}
125126
127+ func mainMPVArgs (socketPath string ) []string {
128+ return []string {
129+ "--no-config" ,
130+ "--idle=yes" ,
131+ "--no-video" ,
132+ "--no-terminal" ,
133+ "--input-ipc-server=" + socketPath ,
134+ }
135+ }
136+
137+ func ambientMPVArgs (socketPath , filePath string ) []string {
138+ return []string {
139+ "--no-config" ,
140+ "--idle=no" ,
141+ "--no-video" ,
142+ "--no-terminal" ,
143+ "--loop-file=inf" ,
144+ "--volume=0" ,
145+ "--pause=yes" ,
146+ "--input-ipc-server=" + socketPath ,
147+ filePath ,
148+ }
149+ }
150+
126151// NewPlayer spawns mpv in idle mode and establishes a JSON-IPC connection
127152// to it. The returned Player is ready to accept Play/Pause/Resume calls.
128153//
@@ -149,52 +174,31 @@ func NewPlayer(ctx context.Context, opts Options) (*Player, error) {
149174 socketPath := filepath .Join (socketDir , "mpv.sock" )
150175
151176 var stderr bytes.Buffer
152- cmd := exec .Command (mpvPath ,
153- "--idle=yes" ,
154- "--no-video" ,
155- "--no-terminal" ,
156- "--really-quiet" ,
157- "--input-ipc-server=" + socketPath ,
158- )
177+ cmd := exec .Command (mpvPath , mainMPVArgs (socketPath )... )
159178 cmd .Stderr = & stderr
160179 if err := cmd .Start (); err != nil {
161180 os .RemoveAll (socketDir )
162181 return nil , fmt .Errorf ("start mpv: %w" , err )
163182 }
164183
165- exited := make (chan error , 1 )
166- go func () { exited <- cmd .Wait () }()
184+ exited := newProcessWaiter (cmd )
167185
168186 if err := waitForSocketOrExit (ctx , socketPath , 5 * time .Second , exited ); err != nil {
169- // If mpv is still alive, terminate it; otherwise it already exited.
170- select {
171- case <- exited :
172- default :
173- _ = cmd .Process .Kill ()
174- <- exited
175- }
187+ terminateMPVProcess (cmd , exited )
176188 os .RemoveAll (socketDir )
177- stderrSnippet := strings .TrimSpace (stderr .String ())
178- if stderrSnippet != "" {
179- return nil , fmt .Errorf ("mpv did not open IPC socket: %w; mpv stderr: %s" , err , stderrSnippet )
180- }
181- return nil , fmt .Errorf ("mpv did not open IPC socket: %w" , err )
189+ return nil , formatMPVStartupError (err , stderr .String (), cmd .Args )
182190 }
183191
184192 ipc , err := dialIPC (socketPath )
185193 if err != nil {
186- select {
187- case <- exited :
188- default :
189- _ = cmd .Process .Kill ()
190- <- exited
191- }
194+ terminateMPVProcess (cmd , exited )
192195 os .RemoveAll (socketDir )
193196 return nil , fmt .Errorf ("dial mpv: %w" , err )
194197 }
195198
196199 p := & Player {
197200 cmd : cmd ,
201+ exited : exited ,
198202 ipc : ipc ,
199203 socketDir : socketDir ,
200204 events : make (chan Event , 32 ),
@@ -300,19 +304,7 @@ func (p *Player) Close() error {
300304 cancel ()
301305 _ = p .ipc .close ()
302306 }
303- if p .cmd != nil && p .cmd .Process != nil {
304- done := make (chan struct {})
305- go func () {
306- _ = p .cmd .Wait ()
307- close (done )
308- }()
309- select {
310- case <- done :
311- case <- time .After (2 * time .Second ):
312- _ = p .cmd .Process .Kill ()
313- <- done
314- }
315- }
307+ waitForMPVProcessExit (p .cmd , p .exited , 2 * time .Second )
316308 if p .socketDir != "" {
317309 _ = os .RemoveAll (p .socketDir )
318310 }
@@ -512,19 +504,124 @@ func clampVolume(v int) int {
512504 }
513505}
514506
507+ type processWaiter struct {
508+ done chan struct {}
509+ err error
510+ }
511+
512+ func newProcessWaiter (cmd * exec.Cmd ) * processWaiter {
513+ w := & processWaiter {done : make (chan struct {})}
514+ go func () {
515+ w .err = cmd .Wait ()
516+ close (w .done )
517+ }()
518+ return w
519+ }
520+
521+ func (w * processWaiter ) Done () <- chan struct {} {
522+ if w == nil {
523+ return nil
524+ }
525+ return w .done
526+ }
527+
528+ func (w * processWaiter ) Err () error {
529+ if w == nil {
530+ return nil
531+ }
532+ <- w .done
533+ return w .err
534+ }
535+
536+ func terminateMPVProcess (cmd * exec.Cmd , exited * processWaiter ) {
537+ if cmd == nil || cmd .Process == nil {
538+ return
539+ }
540+ select {
541+ case <- exited .Done ():
542+ return
543+ default :
544+ }
545+ _ = cmd .Process .Kill ()
546+ waitForMPVProcessExit (cmd , exited , 2 * time .Second )
547+ }
548+
549+ func waitForMPVProcessExit (cmd * exec.Cmd , exited * processWaiter , timeout time.Duration ) {
550+ if cmd == nil || cmd .Process == nil {
551+ return
552+ }
553+ if exited == nil {
554+ done := make (chan struct {})
555+ go func () {
556+ _ = cmd .Wait ()
557+ close (done )
558+ }()
559+ select {
560+ case <- done :
561+ case <- time .After (timeout ):
562+ _ = cmd .Process .Kill ()
563+ <- done
564+ }
565+ return
566+ }
567+
568+ select {
569+ case <- exited .Done ():
570+ return
571+ case <- time .After (timeout ):
572+ _ = cmd .Process .Kill ()
573+ }
574+ select {
575+ case <- exited .Done ():
576+ case <- time .After (500 * time .Millisecond ):
577+ }
578+ }
579+
580+ func formatMPVStartupError (err error , stderr string , args []string ) error {
581+ details := make ([]string , 0 , 3 )
582+ if snippet := trimForError (stderr , 2000 ); snippet != "" {
583+ details = append (details , "mpv stderr: " + snippet )
584+ }
585+ if len (args ) > 0 {
586+ details = append (details , "command: " + formatCommandForError (args ))
587+ }
588+ details = append (details , "hint: lofi-player starts mpv with --no-config to avoid user mpv.conf/scripts; try `mpv --no-config --idle=yes --no-video --no-terminal --input-ipc-server=/tmp/lofi-test.sock` to diagnose mpv itself" )
589+ return fmt .Errorf ("mpv did not open IPC socket: %w; %s" , err , strings .Join (details , "; " ))
590+ }
591+
592+ func trimForError (s string , max int ) string {
593+ s = strings .TrimSpace (s )
594+ if max <= 0 || len (s ) <= max {
595+ return s
596+ }
597+ return s [:max ] + "…"
598+ }
599+
600+ func formatCommandForError (args []string ) string {
601+ formatted := make ([]string , 0 , len (args ))
602+ for _ , arg := range args {
603+ if strings .ContainsAny (arg , " \t \n \" '" ) {
604+ formatted = append (formatted , fmt .Sprintf ("%q" , arg ))
605+ continue
606+ }
607+ formatted = append (formatted , arg )
608+ }
609+ return strings .Join (formatted , " " )
610+ }
611+
515612// waitForSocketOrExit polls for the socket file to appear, returning
516613// early if mpv exits beforehand (in which case the error wraps mpv's
517614// exit status — usually a nil exit means "exited cleanly without error
518615// but never opened the socket", which still counts as failure here).
519- func waitForSocketOrExit (ctx context.Context , path string , timeout time.Duration , exited <- chan error ) error {
616+ func waitForSocketOrExit (ctx context.Context , path string , timeout time.Duration , exited * processWaiter ) error {
520617 deadline := time .Now ().Add (timeout )
521618 for time .Now ().Before (deadline ) {
522619 if _ , err := os .Stat (path ); err == nil {
523620 return nil
524621 }
525622 select {
526- case waitErr := <- exited :
527- if waitErr != nil {
623+ case <- exited . Done () :
624+ if waitErr := exited . Err (); waitErr != nil {
528625 return fmt .Errorf ("mpv exited prematurely: %w" , waitErr )
529626 }
530627 return errors .New ("mpv exited prematurely with status 0" )
0 commit comments