v0.1.0 3373d7c

player_test.go RawHistoryFolder

1package core
2
3import (
4	"errors"
5	"sync"
6	"testing"
7	"time"
8	"unsafe"
9
10	portout "github.com/bnema/purego-pipewire/internal/ports/out"
11	"github.com/bnema/purego-pipewire/internal/ports/out/mocks"
12	"github.com/stretchr/testify/mock"
13)
14
15// defaultTestConfig returns a PlayerConfig suitable for tests that need
16// valid PipeWire format parameters.
17func defaultTestConfig() PlayerConfig {
18	return PlayerConfig{
19		SampleRate:      48000,
20		Channels:        2,
21		FramesPerBuffer: 256,
22	}
23}
24
25// expectStartWithSync sets up mock expectations for a first-time Start()
26// and returns a pointer to a WaitGroup that is signaled when RunMainLoop completes.
27// The caller must call waitOnMainLoop(t, wg) after Start() returns.
28func expectStartWithSync(mockOps *mocks.MockStreamOps, fakeLoop, fakeStream unsafe.Pointer, cfg PlayerConfig) *sync.WaitGroup {
29	wg := &sync.WaitGroup{}
30	wg.Add(1)
31
32	mockOps.EXPECT().CreateMainLoop().Return(fakeLoop, nil)
33	mockOps.EXPECT().CreatePlaybackStream(fakeLoop, "purego-pipewire-player", mock.AnythingOfType("func()")).Return(fakeStream, nil)
34	mockOps.EXPECT().ConnectPlaybackStream(fakeStream, portout.PlaybackFormat{
35		SampleRate:      cfg.SampleRate,
36		Channels:        cfg.Channels,
37		FramesPerBuffer: cfg.FramesPerBuffer,
38	}).Return(nil)
39	mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(nil).Once()
40	mockOps.EXPECT().RunMainLoop(fakeLoop).RunAndReturn(func(unsafe.Pointer) error {
41		wg.Done()
42		return nil
43	})
44
45	return wg
46}
47
48// waitOnMainLoop waits for the RunMainLoop goroutine to finish, with a timeout.
49func waitOnMainLoop(t *testing.T, wg *sync.WaitGroup) {
50	t.Helper()
51	done := make(chan struct{})
52	go func() {
53		wg.Wait()
54		close(done)
55	}()
56	select {
57	case <-done:
58	case <-time.After(2 * time.Second):
59		t.Fatal("timed out waiting for RunMainLoop goroutine")
60	}
61}
62
63func TestPlayerStopIsRestartableButCloseIsTerminal(t *testing.T) {
64	mockOps := mocks.NewMockStreamOps(t)
65	fakeLoop := unsafe.Pointer(uintptr(0xBEEF))
66	fakeStream := unsafe.Pointer(uintptr(0xDEAD))
67	cfg := defaultTestConfig()
68
69	// First start creates resources
70	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
71	// Stop deactivates the stream
72	mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
73	// Restart reactivates existing stream
74	mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(nil)
75	// Close tears down resources
76	mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
77	mockOps.EXPECT().DestroyStream(fakeStream).Return()
78	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
79	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
80
81	p := newPlayer(cfg, PlayerCallbacks{})
82	p.streamOps = mockOps
83
84	// Start from Stopped state should work
85	p.setState(PlayerStateStopped)
86	if err := p.Start(); err != nil {
87		t.Fatalf("Start from Stopped failed: %v", err)
88	}
89	waitOnMainLoop(t, wg)
90
91	// Should be in Playing state after Start
92	if p.State() != PlayerStatePlaying {
93		t.Fatalf("expected Playing state after Start, got %v", p.State())
94	}
95
96	// Stop should transition to Stopped
97	if err := p.Stop(); err != nil {
98		t.Fatalf("Stop failed: %v", err)
99	}
100	if p.State() != PlayerStateStopped {
101		t.Fatalf("expected Stopped state after Stop, got %v", p.State())
102	}
103
104	// Start from Stopped should work again (restartable)
105	if err := p.Start(); err != nil {
106		t.Fatalf("Start from Stopped (restart) failed: %v", err)
107	}
108	if p.State() != PlayerStatePlaying {
109		t.Fatalf("expected Playing state after restart, got %v", p.State())
110	}
111
112	// Close should transition to Closed
113	if err := p.Close(); err != nil {
114		t.Fatalf("Close failed: %v", err)
115	}
116	if p.State() != PlayerStateClosed {
117		t.Fatalf("expected Closed state after Close, got %v", p.State())
118	}
119
120	// Start from Closed should fail (terminal)
121	if err := p.Start(); err == nil {
122		t.Fatal("expected error starting from Closed state, got nil")
123	}
124}
125
126func TestPlayerStartFromClosedReturnsError(t *testing.T) {
127	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
128	p.setState(PlayerStateClosed)
129
130	err := p.Start()
131	if err == nil {
132		t.Fatal("expected error starting from Closed state, got nil")
133	}
134	if !errors.Is(err, ErrPlayerClosed) {
135		t.Fatalf("expected ErrPlayerClosed, got %v", err)
136	}
137}
138
139func TestPlayerStartFromClosingReturnsError(t *testing.T) {
140	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
141	p.setState(PlayerStateClosing)
142
143	err := p.Start()
144	if err == nil {
145		t.Fatal("expected error starting from Closing state, got nil")
146	}
147	if !errors.Is(err, ErrPlayerClosed) {
148		t.Fatalf("expected ErrPlayerClosed, got %v", err)
149	}
150}
151
152func TestPlayerStartTransitionsThroughStartingToPlaying(t *testing.T) {
153	mockOps := mocks.NewMockStreamOps(t)
154	fakeLoop := unsafe.Pointer(uintptr(0xBEEF))
155	fakeStream := unsafe.Pointer(uintptr(0xDEAD))
156	cfg := defaultTestConfig()
157
158	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
159
160	p := newPlayer(cfg, PlayerCallbacks{})
161	p.streamOps = mockOps
162
163	// Initial state should be Idle
164	if p.State() != PlayerStateIdle {
165		t.Fatalf("expected initial state Idle, got %v", p.State())
166	}
167
168	// Start should transition to Playing
169	if err := p.Start(); err != nil {
170		t.Fatalf("Start failed: %v", err)
171	}
172	waitOnMainLoop(t, wg)
173
174	if p.State() != PlayerStatePlaying {
175		t.Fatalf("expected Playing state after Start, got %v", p.State())
176	}
177}
178
179func TestPlayerPauseTransitionsToPaused(t *testing.T) {
180	mockOps := mocks.NewMockStreamOps(t)
181	fakeLoop := unsafe.Pointer(uintptr(0xBEEF))
182	fakeStream := unsafe.Pointer(uintptr(0xDEAD))
183	cfg := defaultTestConfig()
184
185	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
186
187	p := newPlayer(cfg, PlayerCallbacks{})
188	p.streamOps = mockOps
189
190	// Start first
191	if err := p.Start(); err != nil {
192		t.Fatalf("Start failed: %v", err)
193	}
194	waitOnMainLoop(t, wg)
195
196	// Pause should transition to Paused
197	if err := p.Pause(); err != nil {
198		t.Fatalf("Pause failed: %v", err)
199	}
200
201	if p.State() != PlayerStatePaused {
202		t.Fatalf("expected Paused state after Pause, got %v", p.State())
203	}
204}
205
206func TestPlayerStopClearsPausedState(t *testing.T) {
207	mockOps := mocks.NewMockStreamOps(t)
208	fakeLoop := unsafe.Pointer(uintptr(0xBEEF))
209	fakeStream := unsafe.Pointer(uintptr(0xDEAD))
210	cfg := defaultTestConfig()
211
212	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
213	mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
214
215	p := newPlayer(cfg, PlayerCallbacks{})
216	p.streamOps = mockOps
217
218	// Start then pause
219	if err := p.Start(); err != nil {
220		t.Fatalf("Start failed: %v", err)
221	}
222	waitOnMainLoop(t, wg)
223
224	if err := p.Pause(); err != nil {
225		t.Fatalf("Pause failed: %v", err)
226	}
227	if p.State() != PlayerStatePaused {
228		t.Fatalf("expected Paused state, got %v", p.State())
229	}
230
231	// Stop should transition to Stopped (clearing paused state)
232	if err := p.Stop(); err != nil {
233		t.Fatalf("Stop failed: %v", err)
234	}
235
236	if p.State() != PlayerStateStopped {
237		t.Fatalf("expected Stopped state after Stop, got %v", p.State())
238	}
239}
240
241func TestPlayerCloseIsIdempotent(t *testing.T) {
242	mockOps := mocks.NewMockStreamOps(t)
243	fakeLoop := unsafe.Pointer(uintptr(0xBEEF))
244	fakeStream := unsafe.Pointer(uintptr(0xDEAD))
245	cfg := defaultTestConfig()
246
247	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
248	mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
249	mockOps.EXPECT().DestroyStream(fakeStream).Return()
250	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
251	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
252
253	p := newPlayer(cfg, PlayerCallbacks{})
254	p.streamOps = mockOps
255
256	// Start first
257	if err := p.Start(); err != nil {
258		t.Fatalf("Start failed: %v", err)
259	}
260	waitOnMainLoop(t, wg)
261
262	// Close should work
263	if err := p.Close(); err != nil {
264		t.Fatalf("first Close failed: %v", err)
265	}
266	if p.State() != PlayerStateClosed {
267		t.Fatalf("expected Closed state, got %v", p.State())
268	}
269
270	// Second close should also succeed (idempotent)
271	if err := p.Close(); err != nil {
272		t.Fatalf("second Close failed: %v", err)
273	}
274	if p.State() != PlayerStateClosed {
275		t.Fatalf("expected Closed state after second close, got %v", p.State())
276	}
277}
278
279// TestPlayerStopDeactivatesStream verifies that Stop() calls
280// SetStreamActive(false) via StreamOps and transitions to Stopped.
281func TestPlayerStopDeactivatesStream(t *testing.T) {
282	mockOps := mocks.NewMockStreamOps(t)
283	fakeLoop := unsafe.Pointer(uintptr(0x1234))
284	fakeStream := unsafe.Pointer(uintptr(0x5678))
285	cfg := defaultTestConfig()
286
287	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
288	mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
289
290	p := newPlayer(cfg, PlayerCallbacks{})
291	p.streamOps = mockOps
292
293	// Start the player
294	if err := p.Start(); err != nil {
295		t.Fatalf("Start failed: %v", err)
296	}
297	waitOnMainLoop(t, wg)
298	if p.State() != PlayerStatePlaying {
299		t.Fatalf("expected Playing state, got %v", p.State())
300	}
301
302	// Stop should call SetStreamActive(false) and transition to Stopped
303	if err := p.Stop(); err != nil {
304		t.Fatalf("Stop failed: %v", err)
305	}
306	if p.State() != PlayerStateStopped {
307		t.Fatalf("expected Stopped state after Stop, got %v", p.State())
308	}
309}
310
311// TestPlayerStopReturnsDeactivateError verifies that Stop() surfaces the
312// SetStreamActive error and leaves the player in the pre-stop state.
313func TestPlayerStopReturnsDeactivateError(t *testing.T) {
314	mockOps := mocks.NewMockStreamOps(t)
315	fakeLoop := unsafe.Pointer(uintptr(0x5678))
316	fakeStream := unsafe.Pointer(uintptr(0x9ABC))
317	deactivateErr := errors.New("deactivate failed")
318	cfg := defaultTestConfig()
319
320	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
321	mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(deactivateErr)
322
323	p := newPlayer(cfg, PlayerCallbacks{})
324	p.streamOps = mockOps
325
326	// Start the player
327	if err := p.Start(); err != nil {
328		t.Fatalf("Start failed: %v", err)
329	}
330	waitOnMainLoop(t, wg)
331	if p.State() != PlayerStatePlaying {
332		t.Fatalf("expected Playing state, got %v", p.State())
333	}
334
335	// Stop should return the deactivation error
336	err := p.Stop()
337	if err == nil {
338		t.Fatal("expected deactivation error, got nil")
339	}
340	if !errors.Is(err, deactivateErr) {
341		t.Fatalf("expected %v, got %v", deactivateErr, err)
342	}
343
344	// State should remain Playing (not transitioned to Stopped)
345	if p.State() != PlayerStatePlaying {
346		t.Fatalf("expected state to remain Playing after failed deactivation, got %v", p.State())
347	}
348}
349
350// TestPlayerStopWithNilStreamPtrIsNoop verifies that Stop() works
351// when streamOps is set but streamPtr is nil (backward compat).
352func TestPlayerStopWithNilStreamPtrIsNoop(t *testing.T) {
353	mockOps := mocks.NewMockStreamOps(t)
354	cfg := defaultTestConfig()
355
356	p := newPlayer(cfg, PlayerCallbacks{})
357	p.streamOps = mockOps
358
359	// Set state to Playing manually (without going through Start)
360	// to test Stop without an active stream pointer
361	p.setState(PlayerStatePlaying)
362
363	// Stop without a streamPtr should succeed (deactivation is a no-op)
364	if err := p.Stop(); err != nil {
365		t.Fatalf("Stop failed: %v", err)
366	}
367	if p.State() != PlayerStateStopped {
368		t.Fatalf("expected Stopped state, got %v", p.State())
369	}
370}
371
372// TestPlayerCloseTeardownCallsStreamOps verifies that Close() calls
373// DisconnectStream, DestroyStream, QuitMainLoop, and DestroyMainLoop through StreamOps
374// when the corresponding pointers are set.
375func TestPlayerCloseTeardownCallsStreamOps(t *testing.T) {
376	mockOps := mocks.NewMockStreamOps(t)
377	fakeStream := unsafe.Pointer(uintptr(0xAAAA))
378	fakeLoop := unsafe.Pointer(uintptr(0xBBBB))
379
380	// Expect teardown calls in order: DisconnectStream, DestroyStream, QuitMainLoop, DestroyMainLoop.
381	mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
382	mockOps.EXPECT().DestroyStream(fakeStream).Return()
383	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
384	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
385
386	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
387	p.streamOps = mockOps
388	p.streamPtr = fakeStream
389	p.loopPtr = fakeLoop
390
391	// Move to a state that allows Close.
392	p.setState(PlayerStatePlaying)
393
394	if err := p.Close(); err != nil {
395		t.Fatalf("Close failed: %v", err)
396	}
397	if p.State() != PlayerStateClosed {
398		t.Fatalf("expected Closed state, got %v", p.State())
399	}
400
401	// Pointers should be nil after teardown so repeated close is safe.
402	if p.streamPtr != nil {
403		t.Error("expected streamPtr to be nil after teardown")
404	}
405	if p.loopPtr != nil {
406		t.Error("expected loopPtr to be nil after teardown")
407	}
408}
409
410// TestPlayerCloseWithNilPointersIsNoop verifies that teardown is safe
411// when no stream/loop pointers are set.
412func TestPlayerCloseWithNilPointersIsNoop(t *testing.T) {
413	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
414	p.setState(PlayerStatePlaying)
415
416	// No streamOps, no pointers — Close should succeed without panic.
417	if err := p.Close(); err != nil {
418		t.Fatalf("Close failed: %v", err)
419	}
420	if p.State() != PlayerStateClosed {
421		t.Fatalf("expected Closed state, got %v", p.State())
422	}
423}
424
425// TestPlayerRepeatedCloseIsSafe verifies that calling Close() twice
426// with teardown resources does not call StreamOps methods a second time.
427func TestPlayerRepeatedCloseIsSafe(t *testing.T) {
428	mockOps := mocks.NewMockStreamOps(t)
429	fakeStream := unsafe.Pointer(uintptr(0xCCCC))
430	fakeLoop := unsafe.Pointer(uintptr(0xDDDD))
431
432	// Only expect one set of teardown calls.
433	mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
434	mockOps.EXPECT().DestroyStream(fakeStream).Return()
435	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
436	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
437
438	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
439	p.streamOps = mockOps
440	p.streamPtr = fakeStream
441	p.loopPtr = fakeLoop
442	p.setState(PlayerStatePlaying)
443
444	if err := p.Close(); err != nil {
445		t.Fatalf("first Close failed: %v", err)
446	}
447
448	// Second close should succeed and not call StreamOps again.
449	if err := p.Close(); err != nil {
450		t.Fatalf("second Close failed: %v", err)
451	}
452}
453
454// TestPlayerTeardownWithOnlyStreamPtr verifies teardown works
455// when only streamPtr is set (no loopPtr).
456func TestPlayerTeardownWithOnlyStreamPtr(t *testing.T) {
457	mockOps := mocks.NewMockStreamOps(t)
458	fakeStream := unsafe.Pointer(uintptr(0xEEEE))
459
460	mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
461	mockOps.EXPECT().DestroyStream(fakeStream).Return()
462
463	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
464	p.streamOps = mockOps
465	p.streamPtr = fakeStream
466	p.setState(PlayerStatePlaying)
467
468	if err := p.Close(); err != nil {
469		t.Fatalf("Close failed: %v", err)
470	}
471	if p.streamPtr != nil {
472		t.Error("expected streamPtr to be nil after teardown")
473	}
474}
475
476// TestPlayerTeardownWithOnlyLoopPtr verifies teardown works
477// when only loopPtr is set (no streamPtr).
478func TestPlayerTeardownWithOnlyLoopPtr(t *testing.T) {
479	mockOps := mocks.NewMockStreamOps(t)
480	fakeLoop := unsafe.Pointer(uintptr(0xFFFF))
481
482	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
483	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
484
485	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
486	p.streamOps = mockOps
487	p.loopPtr = fakeLoop
488	p.setState(PlayerStatePlaying)
489
490	if err := p.Close(); err != nil {
491		t.Fatalf("Close failed: %v", err)
492	}
493	if p.loopPtr != nil {
494		t.Error("expected loopPtr to be nil after teardown")
495	}
496}
497
498// TestPlayerTeardownCallsDisconnectStream verifies that teardown calls
499// DisconnectStream before DestroyStream when a stream exists.
500func TestPlayerTeardownCallsDisconnectStream(t *testing.T) {
501	mockOps := mocks.NewMockStreamOps(t)
502	fakeStream := unsafe.Pointer(uintptr(0xAAAA))
503	fakeLoop := unsafe.Pointer(uintptr(0xBBBB))
504
505	// Expect disconnect before destroy
506	mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
507	mockOps.EXPECT().DestroyStream(fakeStream).Return()
508	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
509	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
510
511	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
512	p.streamOps = mockOps
513	p.streamPtr = fakeStream
514	p.loopPtr = fakeLoop
515	p.setState(PlayerStatePlaying)
516
517	if err := p.Close(); err != nil {
518		t.Fatalf("Close failed: %v", err)
519	}
520}
521
522// TestPlayerTeardownContinuesOnDisconnectError verifies that teardown
523// continues cleanup even if DisconnectStream returns an error.
524func TestPlayerTeardownContinuesOnDisconnectError(t *testing.T) {
525	mockOps := mocks.NewMockStreamOps(t)
526	fakeStream := unsafe.Pointer(uintptr(0xCCCC))
527	fakeLoop := unsafe.Pointer(uintptr(0xDDDD))
528	disconnectErr := errors.New("disconnect failed")
529
530	// Disconnect fails, but destroy should still be called
531	mockOps.EXPECT().DisconnectStream(fakeStream).Return(disconnectErr)
532	mockOps.EXPECT().DestroyStream(fakeStream).Return()
533	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
534	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
535
536	p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
537	p.streamOps = mockOps
538	p.streamPtr = fakeStream
539	p.loopPtr = fakeLoop
540	p.setState(PlayerStatePlaying)
541
542	// Close should succeed even though disconnect failed (best-effort cleanup)
543	if err := p.Close(); err != nil {
544		t.Fatalf("Close should succeed despite disconnect error: %v", err)
545	}
546}
547
548// TestPlayerRestartActivationFailureCleansUpResources verifies that when
549// SetStreamActive(true) fails during a restart (after Stop), owned stream
550// and loop resources are cleaned up and no stale pointers remain.
551func TestPlayerRestartActivationFailureCleansUpResources(t *testing.T) {
552	mockOps := mocks.NewMockStreamOps(t)
553	fakeLoop := unsafe.Pointer(uintptr(0xCAFE))
554	fakeStream := unsafe.Pointer(uintptr(0xBEEF))
555	cfg := defaultTestConfig()
556	activateErr := errors.New("activate failed on restart")
557
558	// First start: create resources
559	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
560	// Stop: deactivate
561	mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
562	// Restart: activation fails → teardown cleans up
563	mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(activateErr)
564	// teardown should call DisconnectStream, DestroyStream, QuitMainLoop, DestroyMainLoop
565	mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
566	mockOps.EXPECT().DestroyStream(fakeStream).Return()
567	mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
568	mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
569
570	p := newPlayer(cfg, PlayerCallbacks{})
571	p.streamOps = mockOps
572
573	// Start
574	if err := p.Start(); err != nil {
575		t.Fatalf("Start failed: %v", err)
576	}
577	waitOnMainLoop(t, wg)
578
579	// Stop
580	if err := p.Stop(); err != nil {
581		t.Fatalf("Stop failed: %v", err)
582	}
583
584	// Restart should fail because SetStreamActive fails
585	err := p.Start()
586	if err == nil {
587		t.Fatal("expected Start to fail on restart activation failure")
588	}
589	if !errors.Is(err, activateErr) {
590		t.Fatalf("expected activateErr, got %v", err)
591	}
592
593	// Player should be in Error state
594	if p.State() != PlayerStateError {
595		t.Errorf("expected Error state after failed restart activation, got %v", p.State())
596	}
597
598	// Stale pointers must be cleared
599	p.mu.Lock()
600	loopPtr := p.loopPtr
601	streamPtr := p.streamPtr
602	p.mu.Unlock()
603
604	if loopPtr != nil {
605		t.Error("expected loopPtr to be nil after failed restart activation")
606	}
607	if streamPtr != nil {
608		t.Error("expected streamPtr to be nil after failed restart activation")
609	}
610}
611
612// TestPlayerRestartDoesNotStartSecondMainLoop verifies that when restarting
613// after Stop (reusing existing resources), no second RunMainLoop call is
614// made — only SetStreamActive(true) is called.
615func TestPlayerRestartDoesNotStartSecondMainLoop(t *testing.T) {
616	mockOps := mocks.NewMockStreamOps(t)
617	fakeLoop := unsafe.Pointer(uintptr(0xCAFE))
618	fakeStream := unsafe.Pointer(uintptr(0xBEEF))
619	cfg := defaultTestConfig()
620
621	// First start: full resource creation + RunMainLoop
622	wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
623	// Stop: deactivate
624	mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
625	// Restart: only SetStreamActive(true) — NO CreateMainLoop, NO RunMainLoop
626	mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(nil)
627
628	p := newPlayer(cfg, PlayerCallbacks{})
629	p.streamOps = mockOps
630
631	// Start
632	if err := p.Start(); err != nil {
633		t.Fatalf("Start failed: %v", err)
634	}
635	waitOnMainLoop(t, wg)
636
637	// Stop
638	if err := p.Stop(); err != nil {
639		t.Fatalf("Stop failed: %v", err)
640	}
641
642	// Restart — mock will fail if any extra calls are made
643	if err := p.Start(); err != nil {
644		t.Fatalf("Restart failed: %v", err)
645	}
646
647	if p.State() != PlayerStatePlaying {
648		t.Fatalf("expected Playing state after restart, got %v", p.State())
649	}
650}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 8

↑↓ moveenter openesc close