CI / test (push) Failing after 38s
- Beranda: kolom tengah CTA 'masuk workspace' (klikable), strip Disclaimer dan Info aplikasi (versi/repo/config); geometri satu sumber (homeGeom). - Workspace: baris toolbar — pill Cari (klik atau '/' → cari-lompat daftar) + tombol [+ stack]/[konversi] klikable (sementara keduanya jalur konverter). - Detail kontainer: kolom kanan 'Aksi Tab' (mock TAB ACTION) berisi aksi tab aktif yang bisa diklik; disembunyikan di layar sempit agar tab bar utuh. - Footer: hint aksi diprioritaskan; status diagnostik dibuang bila tak muat. - internal/version: versi build dipakai CLI & strip Info aplikasi. - audio: Play/Status aman untuk Engine nil (Model zero-value di tes). - Tes: 3 skenario E2E baru (CTA, cari toolbar, kolom aksi) + tes layout.
185 lines
3.7 KiB
Go
185 lines
3.7 KiB
Go
// Package audio — efek suara interaktif (F-024).
|
|
//
|
|
// Inisialisasi asynchronous: New() tidak pernah memblok startup.
|
|
// Bila tidak ada audio device/server, engine tetap hidup dan Play() menjadi
|
|
// no-op (degradasi anggun); Status() dipakai status bar & `tia-love doctor`.
|
|
package audio
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ebitengine/oto/v3"
|
|
)
|
|
|
|
type Options struct {
|
|
Enabled bool
|
|
Volume int // 0-100
|
|
MuteOnSSH bool
|
|
}
|
|
|
|
type Engine struct {
|
|
opts Options
|
|
muted bool
|
|
ready chan struct{}
|
|
|
|
mu sync.RWMutex
|
|
ctx *oto.Context
|
|
sounds map[Event][]byte
|
|
initErr error
|
|
}
|
|
|
|
const initTimeout = 3 * time.Second
|
|
|
|
// New membuat Engine dan — bila aktif — memulai init audio di goroutine
|
|
// terpisah sehingga waktu startup aplikasi tidak terpengaruh.
|
|
//
|
|
// shortcut: engine yang disabled/muted tidak dapat diaktifkan ulang tanpa
|
|
// restart; upgrade saat layar Pengaturan (Phase 1, F-024) butuh toggle langsung.
|
|
func New(opts Options) *Engine {
|
|
e := &Engine{
|
|
opts: opts,
|
|
ready: make(chan struct{}),
|
|
muted: opts.MuteOnSSH && InSSHSession(),
|
|
}
|
|
switch {
|
|
case !opts.Enabled:
|
|
e.setErr(errors.New("efek suara dimatikan"))
|
|
close(e.ready)
|
|
case e.muted:
|
|
e.setErr(errors.New("auto-mute: sesi SSH"))
|
|
close(e.ready)
|
|
default:
|
|
go e.init()
|
|
}
|
|
return e
|
|
}
|
|
|
|
func (e *Engine) init() {
|
|
defer close(e.ready)
|
|
|
|
sounds, err := loadSounds()
|
|
if err != nil {
|
|
e.setErr(err)
|
|
return
|
|
}
|
|
ctx, readyCh, err := oto.NewContext(&oto.NewContextOptions{
|
|
SampleRate: 44100,
|
|
ChannelCount: 1,
|
|
Format: oto.FormatSignedInt16LE,
|
|
})
|
|
if err != nil {
|
|
e.setErr(err)
|
|
return
|
|
}
|
|
select {
|
|
case <-readyCh:
|
|
case <-time.After(initTimeout):
|
|
e.setErr(errors.New("audio: device tidak siap dalam 3 detik"))
|
|
return
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
e.setErr(err)
|
|
return
|
|
}
|
|
e.mu.Lock()
|
|
e.ctx, e.sounds = ctx, sounds
|
|
e.mu.Unlock()
|
|
}
|
|
|
|
func (e *Engine) setErr(err error) {
|
|
e.mu.Lock()
|
|
e.initErr = err
|
|
e.mu.Unlock()
|
|
}
|
|
|
|
// Ready melaporkan apakah audio benar-benar siap memutar suara.
|
|
func (e *Engine) Ready() bool {
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
return e.ctx != nil
|
|
}
|
|
|
|
// Err mengembalikan penyebab engine tidak siap (nil bila siap/ masih init).
|
|
func (e *Engine) Err() error {
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
return e.initErr
|
|
}
|
|
|
|
// Muted: auto-mute karena sesi SSH.
|
|
func (e *Engine) Muted() bool { return e.muted }
|
|
|
|
// WaitReady menunggu init selesai (untuk tes & `doctor`).
|
|
func (e *Engine) WaitReady(d time.Duration) bool {
|
|
select {
|
|
case <-e.ready:
|
|
case <-time.After(d):
|
|
return false
|
|
}
|
|
return e.Ready()
|
|
}
|
|
|
|
// Status ringkas untuk status bar / `tia-love doctor`.
|
|
func (e *Engine) Status() string {
|
|
if e == nil { // Engine zero-value aman (Model tanpa audio.New)
|
|
return "nonaktif"
|
|
}
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
switch {
|
|
case e.muted:
|
|
return "SSH auto-mute"
|
|
case !e.opts.Enabled:
|
|
return "nonaktif"
|
|
case e.ctx != nil:
|
|
return "siap"
|
|
case e.initErr != nil:
|
|
return "gagal: " + e.initErr.Error()
|
|
default:
|
|
return "menyiapkan…"
|
|
}
|
|
}
|
|
|
|
// Play memutar efek; fire-and-forget, aman dipanggil dari goroutine mana pun.
|
|
// No-op bila engine belum siap / dimatikan / muted.
|
|
func (e *Engine) Play(ev Event) {
|
|
if e == nil || e.muted || !e.opts.Enabled {
|
|
return
|
|
}
|
|
e.mu.RLock()
|
|
ctx, pcm := e.ctx, e.sounds[ev]
|
|
e.mu.RUnlock()
|
|
if ctx == nil || len(pcm) == 0 {
|
|
return
|
|
}
|
|
vol := float64(e.opts.Volume) / 100
|
|
if vol < 0 {
|
|
vol = 0
|
|
} else if vol > 1 {
|
|
vol = 1
|
|
}
|
|
p := ctx.NewPlayer(bytes.NewReader(pcm))
|
|
p.SetVolume(vol)
|
|
p.Play()
|
|
go func() {
|
|
for p.IsPlaying() {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
_ = p.Close()
|
|
}()
|
|
}
|
|
|
|
// InSSHSession mendeteksi sesi SSH lewat env standar.
|
|
func InSSHSession() bool {
|
|
for _, k := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} {
|
|
if os.Getenv(k) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|