← poker-tutor
import Foundation
#if canImport(AVFoundation)
import AVFoundation
#elseif os(Android)
import SkipAV // Skip's AVAudioPlayer, over Android's MediaPlayer
import SkipFuse // for the log
#endif
import PokerCore
import PokerSession
#if canImport(AVFoundation) || os(Android)
/// The table sounds.
///
/// Four short files, synthesised by `Scripts/make-sounds.py` so nothing has to be licensed and the
/// repo stays self-contained. They are deliberately quiet: a sound you hear a hundred times an
/// hour has to sit under the game rather than on top of it.
@MainActor
final class Sound {
static let shared = Sound()
enum Cue: String, CaseIterable {
case chips, fold, knock, pot
}
/// Off means silent, and it stays that way between launches.
var enabled: Bool {
didSet { UserDefaults.standard.set(enabled, forKey: Self.defaultsKey) }
}
private static var defaultsKey: String { AppVariant.current.key("soundEnabled") }
/// Two players per cue, alternated, so a quick call-then-raise does not cut itself off.
private var players: [Cue: [AVAudioPlayer]] = [:]
private var next: [Cue: Int] = [:]
private init() {
if UserDefaults.standard.object(forKey: Self.defaultsKey) == nil {
enabled = true
} else {
enabled = UserDefaults.standard.bool(forKey: Self.defaultsKey)
}
configureSession()
for cue in Cue.allCases {
guard let url = Bundle.module.url(forResource: cue.rawValue, withExtension: "wav") else {
#if os(Android)
Logger(subsystem: AppVariant.current.bundleID, category: "sound").warning("no sound file for \(cue.rawValue)")
#endif
continue
}
#if os(Android)
Logger(subsystem: AppVariant.current.bundleID, category: "sound").info("sound \(cue.rawValue) at \(url.path)")
#endif
players[cue] = (0..<2).compactMap { _ in
guard let player = try? AVAudioPlayer(contentsOf: url) else { return nil }
player.volume = cue == .fold ? 0.5 : 0.65
player.prepareToPlay()
return player
}
next[cue] = 0
}
}
/// On a phone this makes the game respect the silent switch and duck out of the way of anything
/// already playing, which is the polite behaviour for a game with incidental sound.
private func configureSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
}
func play(_ cue: Cue) {
guard enabled, let bank = players[cue], !bank.isEmpty else { return }
let index = (next[cue] ?? 0) % bank.count
next[cue] = index + 1
let player = bank[index]
player.currentTime = 0
player.play()
}
/// What one decision sounds like.
func play(for applied: TableSession.AppliedAction) {
switch applied.action {
case .fold: play(.fold)
case .check: play(.knock)
case .call, .bet, .raise: play(.chips)
}
}
}
#else
/// No sound here: the table plays silently, and the setting still exists so the screens and the
/// suite do not have to know.
@MainActor
final class Sound {
static let shared = Sound()
enum Cue: String, CaseIterable {
case chips, fold, knock, pot
}
var enabled: Bool {
didSet { UserDefaults.standard.set(enabled, forKey: Self.defaultsKey) }
}
private static var defaultsKey: String { AppVariant.current.key("soundEnabled") }
private init() {
if UserDefaults.standard.object(forKey: Self.defaultsKey) == nil {
enabled = true
} else {
enabled = UserDefaults.standard.bool(forKey: Self.defaultsKey)
}
}
func play(_ cue: Cue) {}
func play(for applied: TableSession.AppliedAction) {}
}
#endif