#### Benben
#### Copyright (C) 2023-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or
#### modify it under the terms of the GNU Affero General Public
#### License as published by the Free Software Foundation, either
#### version 3 of the License, or (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program. If not, see <https://www.gnu.org/licenses/>.
require "remislang"
require "./theme"
require "../../ui"
require "../../banners"
require "./vumeter"
require "./tagfield"
require "./banner"
Signal::WINCH.trap do
# This could be nil if we're rendering
Benben.ui?.try do |rawUI|
if ui = rawUI.as?(Benben::OrigUI)
# Reinitialize S-Lang and then update the screen size
ui.uiLock.synchronize do
LibSLang.SLsmg_init_smg()
RemiSlang::Screen.updateScreenSize
end
end
end
end
Signal::TSTP.trap do
Benben.dlog!("Suspending")
# Let the UI know we need to suspend
Benben.ui?.try do |rawUI|
if ui = rawUI.as?(Benben::OrigUI)
ui.uiLock.synchronize do
# The OrigUI needs to remember its current TTY state so it can properly
# restore it later. Without this, input handling goes wonky and starts
# needing Enter to be pressed after each key input.
LibC.tcgetattr(STDOUT.fd, out currentAttrs)
ui.currentAttrs = currentAttrs
end
end
end
# Send SIGSTOP
Process.signal(Signal::STOP, Process.pid)
end
Signal::CONT.trap do
Benben.dlog!("Unsuspending")
# This could be nil if we're rendering
Benben.ui?.try do |rawUI|
if ui = rawUI.as?(Benben::OrigUI)
ui.uiLock.synchronize do
# The OrigUI needs to restore its TTY state from before the suspend so
# it can properly restore it later. Without this, input handling goes
# wonky and starts needing Enter to be pressed after each key input.
ui.currentAttrs.try do |attrs|
LibC.tcsetattr(STDOUT.fd, Termios::AttributeSelection::TCSANOW, pointerof(attrs))
end
end
# Redraw the screen
ui.queueRedrawWholeScreen
end
end
end
module Benben
alias Slang = RemiSlang
alias SLScreen = RemiSlang::Screen
alias SLInput = RemiSlang::Input
class OrigUI < UI
property currentAttrs : LibC::Termios?
# How often the CPU usage is refreshed, in milliseconds.
CPU_USAGE_UPDATE_FREQ = 1000
# How long a message is displayed.
MESSAGE_TIME = 5.seconds
# 30 fps, should be more than plenty for a TUI.
FPS_CAP = 1000.0 / 30.0
##
## These are messages that are displayed when `h` is pressed. These must be
## 4-5 lines each after the call to String#strip.
HELP_MESSAGE = %|
n - Next p - Prev t - Tag lang a - Vol up ] - Loop up
q - Quit e - EQ c - Toggle soft clip z - Vol down [ - Loop down
s - Toggle stereo enhancer Space - Pause/Unpause
R - Redraw screen S - Stop after song C - Reload song configs
> - Seek forward < - Seek backward
|.strip.lines
HELP_MESSAGE_MODULES = %|
n - Next p - Prev i - Toggle Interp. a - Vol up
q - Quit e - EQ c - Toggle soft clip z - Vol down
s - Toggle stereo enhancer Space - Pause/Unpause
R - Redraw screen S - Stop after song C - Reload song configs
> - Next pattern < - Prev pattern
|.strip.lines
HELP_MESSAGE_MIDI = %|
n - Next p - Prev x - Toggle Chorus a - Vol up
q - Quit e - EQ c - Toggle soft clip z - Vol down
s - Toggle stereo enhancer Space - Pause/Unpause
R - Redraw screen S - Stop after song C - Reload song configs
|.strip.lines
enum Mode
Playing
Paused
PlayingStopAfterCurrent
PausedStopAfterCurrent
Undefined
end
##
## Theme
##
@@theme : Theme?
class_getter! theme
##
## Headers and Tags
##
INFO_LINE_HEADER = "Info: "
TAG_FIELD_TRACK = 0
TAG_FIELD_GAME = 1
TAG_FIELD_AUTHOR = 2
TAG_FIELD_SYSTEM = 3
TAG_FIELD_RELEASE_DATE = 4
TAG_FIELD_CREATOR = 5
TAG_FIELD_RVA = 6
TAG_FIELD_MODE = 7
@tagFields : Array(TagField) = [] of TagField
getter uiLock : Mutex = Mutex.new
# Screen info
getter lastWidth : Int32 = 0
getter lastHeight : Int32 = 0
# Whether or not the UI is running
@running : AtomicBool = AtomicBool.new(false)
# Whether or not to use animations.
getter? animations : Bool = true
# Sample rates
@sampleRate : UInt32
@invSampleRate : Float64
# Last time the tag language was swapped (only when auto-swapping tag
# languages).
@lastTagSwap : Time = Time.local
# Current volume
@mainVolume : Float64 = -1.0
@lastShownVolume : Float64 = -1.0
# Most recent messages
@lastMsg : String = ""
@lastErrMsg : String = ""
# Whether or not we're currently showing help
@helpTimeout : Time?
# Current playback mode
@mode : Mode = Mode::Playing
@lastMode : Mode = Mode::Undefined
# The VU meter.
private getter! vu : VUMeter?
# Banner stuff
@banner : Banner
# Tag display info
@playingFile : PlayableFile?
@tag : TagInfo?
@gd3TagLang : Gd3TagLang
# Information Line
@infoLine : String = ""
# Progress bar information.
@progressMax : UInt64 = 1u64
@progressPos : UInt64 = 1u64
@totalTracks : UInt32 = 0
@curTrack : UInt32 = 0
@currentLoop : UInt32 = 0
@maxLoops : Int32 = 0
@progressBarLabel : String = ""
@progressBarLabelWidth : Int32 = 0
@eqEnabled : Bool = false
@stereoEnhancerEnabled : Bool = false
@softClippingEnabled : Bool = true
@reverbEnabled : Bool = false
@hasSongConfig : Bool = false
@progressBarCurTime : String = ""
@progressBarTotalTime : String = ""
# When the CPU time was last drawn
@cpuLastUpdated : Time::Span = Time.monotonic - CPU_USAGE_UPDATE_FREQ.seconds
# Local copy of the "stop after current song" functionality so we can
# compare it to what the player has.
@stopAfterCurrent : Bool = false
# Where the banner ends and the main content area begins.
@mainAreaStartRow : Int32 = 0
# Communication channels
protected getter keyChan : Channel(KeyCommand) = Channel(KeyCommand).new(10)
# Song queue total time
@totalTime : Time::Span? = nil
@totalTimeMut : Mutex = Mutex.new
@totalTimeStr : String = ""
def initialize
@@theme = Theme.new(Benben.theme)
@animations = Benben.config.uiConfig.animationsEnabled?
@gd3TagLang = case Benben.config.vgm.preferredGd3Lang
when .toggle_prefer_japanese?
Gd3TagLang::Japanese
when .toggle_prefer_english?
Gd3TagLang::English
else
Benben.config.vgm.preferredGd3Lang
end
@sampleRate = Benben.config.sampleRate
@invSampleRate = 1.0 / @sampleRate
@tagFields << TagField.new # Track
@tagFields << TagField.new # Game/Album
@tagFields << TagField.new # Author/Artist
@tagFields << TagField.new # System/Genre
@tagFields << TagField.new # Release Date
@tagFields << TagField.new # Creator/Composer
@tagFields << TagField.new # RVA
@tagFields << TagField.new # Mode
@banner = Banner.getBanner(@animations)
@mainAreaStartRow = @banner.size
end
###
### Abstract Method Implementations
###
def apply(settings : EphemeralConfig) : Nil
@stereoEnhancerEnabled = settings.enableStereoEnhancer?
@eqEnabled = settings.equalizerEnabled?
@softClippingEnabled = settings.enableSoftClipping?
@reverbEnabled = settings.reverbEnabled?
@hasSongConfig = settings.songConfigApplied?
@mainVolume = settings.mainVolume
Benben.dlog!("Initial main volume from settings application: #{@mainVolume}")
updateProgressBarInfo
end
###
### Messages
###
@messageTimeout : Time?
@errorQueue : Deque(String) = Deque(String).new(1)
@messageQueue : Deque(String) = Deque(String).new(1)
# Updates the messages display.
private def updateMessages : Nil
forceShowError : Bool = false
forceShow : Bool = false
offset = @lastHeight - 2
# First, dequeue any messages that we've received.
select
when msg = @errMsgChan.receive
if forceShowError = msg[1]
@errorQueue.insert(0, msg[0])
else
@errorQueue << msg[0]
end
Benben.dlog!("Error message queued (immediate? #{msg[1]}): #{msg[0]}")
when msg = @msgChan.receive
if forceShow = msg[1]
@messageQueue.insert(0, msg[0])
else
@messageQueue << msg[0]
end
Benben.dlog!("Message queued (immediate? #{msg[1]}): #{msg[0]}")
else nil
end
# Now show the messages. The ones that are forced to the front of the
# queue get priority. Errors are always favored over normal messages.
if forceShowError
Benben.dlog!("Showing immediate error message")
SLScreen.goto(0, offset)
SLScreen.clearEol
OrigUI.theme.writeError("Error: ", @errorQueue.pop)
@messageTimeout = Time.local + MESSAGE_TIME
elsif forceShow
Benben.dlog!("Showing immediate message")
SLScreen.goto(0, offset)
SLScreen.clearEol
SLScreen.write(@messageQueue.pop)
@messageTimeout = Time.local + MESSAGE_TIME
else
# No priority messages. Check the timeout.
if timeout = @messageTimeout
if timeout < Time.local
# Timeout is done, we can clear the messages line.
Benben.dlog!("Message timeout finished")
SLScreen.goto(0, offset)
SLScreen.clearEol
@messageTimeout = nil
end
else
# Timeout isn't set, which means we can do a new message if we have
# any. Again, we favor errors over normal messages.
if !@errorQueue.empty?
Benben.dlog!("Displaying new error message")
SLScreen.goto(0, offset)
SLScreen.clearEol
OrigUI.theme.writeError("Error: ", @errorQueue.pop)
@messageTimeout = Time.local + MESSAGE_TIME
elsif !@messageQueue.empty?
Benben.dlog!("Displaying new message")
SLScreen.goto(0, offset)
SLScreen.clearEol
SLScreen.write(@messageQueue.pop)
@messageTimeout = Time.local + MESSAGE_TIME
end
end
end
end
# Updates the help display.
private def updateHelp : Nil
offset = @mainAreaStartRow + 8
# First, dequeue any messages that we've received.
select
when msg = @helpChan.receive
if msg
# Clear the drawing area, then draw the help message
SLScreen.fill(0, offset,
@lastWidth.to_u32!,
Math.max(@lastHeight - offset - 2, 6).to_u32,
' ')
case @playingFile
when ModuleFile
HELP_MESSAGE_MODULES.each_with_index do |line, idx|
SLScreen.write(0, offset + idx + 1, line)
end
when MidiFile
HELP_MESSAGE_MIDI.each_with_index do |line, idx|
SLScreen.write(0, offset + idx + 1, line)
end
else
HELP_MESSAGE.each_with_index do |line, idx|
SLScreen.write(0, offset + idx + 1, line)
end
end
# Set the timeout
@helpTimeout = Time.local + MESSAGE_TIME
else
# Immediately stop showing help
@helpTimeout = Time.local
end
else nil
end
# Check to see if we need to stop showing help.
if timeout = @helpTimeout
if Time.local >= timeout
@helpTimeout = nil
# Clear the drawing area
SLScreen.fill(0, offset, (@lastWidth - 2).to_u32!, 6u32, ' ')
updateSongQueue
end
end
end
###
### Tag Handling
###
private def updateTagFields : Nil
tag = @tag
case tag
in VgmTagInfo
@gd3TagLang = case Benben.config.vgm.preferredGd3Lang
in .toggle_prefer_japanese?, .japanese?
Gd3TagLang::English # Backwards - about to switch anyway
in .toggle_prefer_english?, .english?
Gd3TagLang::Japanese # Backwards - about to switch anyway
end
toggleGD3Language
@tagFields[TAG_FIELD_RELEASE_DATE].set("Release Date: ", tag.date)
@tagFields[TAG_FIELD_CREATOR ].set("VGM Creator: ", tag.creator)
@tagFields[TAG_FIELD_RVA ].set("RVA: ", "N/A")
in ModuleTagInfo
@tagFields[TAG_FIELD_TRACK ].set("Title: ", tag.title)
@tagFields[TAG_FIELD_GAME ].set("Type: ", tag.type)
@tagFields[TAG_FIELD_AUTHOR ].set("Tracks: ", tag.tracks)
@tagFields[TAG_FIELD_SYSTEM ].set("Patterns: ", tag.patterns)
@tagFields[TAG_FIELD_RELEASE_DATE].set("Channels: ", tag.tracksPerPat)
@tagFields[TAG_FIELD_CREATOR ].set("Instruments: ", tag.instruments)
@tagFields[TAG_FIELD_RVA ].set("RVA: ", "N/A")
in VorbisTagInfo
@tagFields[TAG_FIELD_TRACK ].set("Title: ", tag.title)
@tagFields[TAG_FIELD_GAME ].set("Album: ", tag.album)
@tagFields[TAG_FIELD_AUTHOR ].set("Artist: ", tag.artist)
@tagFields[TAG_FIELD_SYSTEM ].set("Genre: ", tag.genre)
@tagFields[TAG_FIELD_RELEASE_DATE].set("Release Date: ", tag.date)
if !tag.composer.empty?
@tagFields[TAG_FIELD_CREATOR].set("Composer: ", tag.composer)
else
@tagFields[TAG_FIELD_CREATOR].set("Performer: ", tag.performer)
end
@tagFields[TAG_FIELD_RVA ].set("RVA: ", Benben.config.replayGain.to_s)
in Id3TagInfo
@tagFields[TAG_FIELD_TRACK ].set("Title: ", tag.title)
@tagFields[TAG_FIELD_GAME ].set("Album: ", tag.album)
@tagFields[TAG_FIELD_AUTHOR ].set("Artist: ", tag.artist)
@tagFields[TAG_FIELD_SYSTEM ].set("Genre: ", tag.genre)
@tagFields[TAG_FIELD_RELEASE_DATE].set("Release Date: ", tag.date)
if !tag.composer.empty?
@tagFields[TAG_FIELD_CREATOR].set("Composer: ", tag.composer)
else
@tagFields[TAG_FIELD_CREATOR].set("Performer: ", tag.performer)
end
@tagFields[TAG_FIELD_RVA ].set("RVA: ", Benben.config.replayGain.to_s)
in MidiTagInfo
# MIDI files don't have tag information, so we repurpose the fields to
# show other information.
if midi = Benben.player.curFile.as?(MidiFile)
@tagFields[TAG_FIELD_TRACK ].set("File: ", Path[midi.filename].basename)
@tagFields[TAG_FIELD_GAME ].set("Reverb: ", Benben.config.midi.reverbType.to_s)
@tagFields[TAG_FIELD_AUTHOR ].set("Reverb Amt: ", Benben.config.midi.reverbAmount.to_s)
@tagFields[TAG_FIELD_SYSTEM ].set("Chorus Amt: ", Benben.config.midi.chorusAmount.to_s)
@tagFields[TAG_FIELD_RELEASE_DATE].set("V. Filter: ", Benben.config.midi.voiceFilterType.to_s)
@tagFields[TAG_FIELD_CREATOR ].set("C. Filter: ", Benben.config.midi.channelFilterType.to_s)
else
RemiLib.log.warn("UI has MidiTagInfo, but the player does not have a MidiFile")
@tagFields[TAG_FIELD_TRACK ].set("File: ", "")
@tagFields[TAG_FIELD_GAME ].set("Reverb: ", "")
@tagFields[TAG_FIELD_AUTHOR ].set("Reverb Amt: ", "")
@tagFields[TAG_FIELD_SYSTEM ].set("Chorus Amt: ", "")
@tagFields[TAG_FIELD_RELEASE_DATE].set("V. Filter: ", "")
@tagFields[TAG_FIELD_CREATOR ].set("C. Filter: ", "")
end
@tagFields[TAG_FIELD_RVA ].set("RVA: ", "N/A")
in PcmTagInfo
# PCM files don't have tag information, so we repurpose the fields to
# show other information.
if pcm = Benben.player.curFile.as?(PcmFile)
@tagFields[TAG_FIELD_TRACK ].set("File: ", Path[pcm.filename].basename)
@tagFields[TAG_FIELD_GAME ].set("Sample Rate: ", pcm.sampleRate.to_s)
@tagFields[TAG_FIELD_AUTHOR ].set("Samples: ", formatStr("~:d", pcm.totalSamples))
@tagFields[TAG_FIELD_SYSTEM ].set("Type: ", pcm.type)
@tagFields[TAG_FIELD_RELEASE_DATE].set("Channels: ", pcm.channels.to_s)
@tagFields[TAG_FIELD_CREATOR ].set("Resampling: ", (Benben.config.sampleRate == pcm.sampleRate ? "No" : "Yes"))
else
RemiLib.log.warn("UI has PcmTagInfo, but the player does not have a PcmFile")
@tagFields[TAG_FIELD_TRACK ].set("File: ", "")
@tagFields[TAG_FIELD_GAME ].set("Sample Rate: ", "")
@tagFields[TAG_FIELD_AUTHOR ].set("Samples: ", "")
@tagFields[TAG_FIELD_SYSTEM ].set("Typ: ", "")
@tagFields[TAG_FIELD_RELEASE_DATE].set("Channels: ", "")
@tagFields[TAG_FIELD_RELEASE_DATE].set("Resampling: ", "")
end
@tagFields[TAG_FIELD_RVA ].set("RVA: ", "N/A")
in QoaTagInfo
# QOA files don't have tag information, so we repurpose the fields to
# show other information.
if qoa = Benben.player.curFile.as?(QoaFile)
@tagFields[TAG_FIELD_TRACK ].set("File: ", Path[qoa.filename].basename)
@tagFields[TAG_FIELD_GAME ].set("Sample Rate: ", qoa.sampleRate.to_s)
@tagFields[TAG_FIELD_AUTHOR ].set("Samples: ", formatStr("~:d", qoa.totalSamples))
@tagFields[TAG_FIELD_SYSTEM ].set("Type: ", "QOA")
@tagFields[TAG_FIELD_RELEASE_DATE].set("Channels: ", qoa.channels.to_s)
@tagFields[TAG_FIELD_CREATOR ].set("Resampling: ", (Benben.config.sampleRate == qoa.sampleRate ? "No" : "Yes"))
else
RemiLib.log.warn("UI has QoaTagInfo, but the player does not have a QoaFile")
@tagFields[TAG_FIELD_TRACK ].set("File: ", "")
@tagFields[TAG_FIELD_GAME ].set("Sample Rate: ", "")
@tagFields[TAG_FIELD_AUTHOR ].set("Samples: ", "")
@tagFields[TAG_FIELD_SYSTEM ].set("Typ: ", "")
@tagFields[TAG_FIELD_RELEASE_DATE].set("Channels: ", "")
@tagFields[TAG_FIELD_RELEASE_DATE].set("Resampling: ", "")
end
@tagFields[TAG_FIELD_RVA ].set("RVA: ", "N/A")
in TagInfo, Nil
@tagFields[TAG_FIELD_TRACK ].set("Title: ", "")
@tagFields[TAG_FIELD_GAME ].set("Game: ", "")
@tagFields[TAG_FIELD_AUTHOR ].set("Author: ", "")
@tagFields[TAG_FIELD_SYSTEM ].set("System: ", "")
@tagFields[TAG_FIELD_RELEASE_DATE].set("Release Date: ", "")
@tagFields[TAG_FIELD_CREATOR ].set("VGM Creator: ", "")
@tagFields[TAG_FIELD_RVA ].set("RVA: ", "N/A")
end
end
# Toggles the displayed GD3 tag language.
private def toggleGD3Language : Nil
if (tag = @tag).is_a?(VgmTagInfo)
case @gd3TagLang
when .japanese?
if tag.title.empty? && tag.album.empty? &&
tag.artist.empty? && tag.systemEn.empty?
# Just stay on Japanese, no information in the English tag.
@tagFields[TAG_FIELD_TRACK ].set("Title: ", tag.titleJp)
@tagFields[TAG_FIELD_GAME ].set("Game: ", tag.gameJp)
@tagFields[TAG_FIELD_AUTHOR ].set("Author: ", tag.artistJp)
@tagFields[TAG_FIELD_SYSTEM ].set("System: ", tag.systemJp)
@gd3TagLang = Gd3TagLang::Japanese
else
# Switch
@tagFields[TAG_FIELD_TRACK ].set("Title: ", tag.title)
@tagFields[TAG_FIELD_GAME ].set("Game: ", tag.album)
@tagFields[TAG_FIELD_AUTHOR ].set("Author: ", tag.artist)
@tagFields[TAG_FIELD_SYSTEM ].set("System: ", tag.systemEn)
@gd3TagLang = Gd3TagLang::English
end
when .english?
if tag.titleJp.empty? && tag.gameJp.empty? &&
tag.artistJp.empty? && tag.systemJp.empty?
# Just stay on English, no information in the Japanese tag.
@tagFields[TAG_FIELD_TRACK ].set("Title: ", tag.title)
@tagFields[TAG_FIELD_GAME ].set("Game: ", tag.album)
@tagFields[TAG_FIELD_AUTHOR ].set("Author: ", tag.artist)
@tagFields[TAG_FIELD_SYSTEM ].set("System: ", tag.systemEn)
@gd3TagLang = Gd3TagLang::English
else
# Switch
@tagFields[TAG_FIELD_TRACK ].set("Title: ", tag.titleJp)
@tagFields[TAG_FIELD_GAME ].set("Game: ", tag.gameJp)
@tagFields[TAG_FIELD_AUTHOR ].set("Author: ", tag.artistJp)
@tagFields[TAG_FIELD_SYSTEM ].set("System: ", tag.systemJp)
@gd3TagLang = Gd3TagLang::Japanese
end
else raise "UI's current GD3 tag language is unexpected"
end
end
end
# Displays the tag information.
private def updateTagDisplay(force : Bool = false) : Nil
half = @lastWidth.tdiv(2)
@tagFields[TAG_FIELD_TRACK].move(0, @mainAreaStartRow)
@tagFields[TAG_FIELD_GAME].move(half, @mainAreaStartRow)
@tagFields[TAG_FIELD_AUTHOR].move(0, @mainAreaStartRow + 1)
@tagFields[TAG_FIELD_SYSTEM].move(half, @mainAreaStartRow + 1)
@tagFields[TAG_FIELD_RELEASE_DATE].move(0, @mainAreaStartRow + 2)
@tagFields[TAG_FIELD_CREATOR].move(half, @mainAreaStartRow + 2)
@tagFields[TAG_FIELD_RVA].move(0, @mainAreaStartRow + 3)
@tagFields[TAG_FIELD_MODE].move(half, @mainAreaStartRow + 3)
unless force
@tagFields.each(&.update)
else
@tagFields.each(&.redraw)
end
end
###
### Info Line
###
# Displays the Info Line
private def updateInfoLine : Nil
@infoLine = Benben.player.infoLine
SLScreen.goto(0, @mainAreaStartRow + 4)
SLScreen.clearEol
OrigUI.theme.writeField(INFO_LINE_HEADER, Utils.clipLine(@infoLine))
end
###
### Mode Field
###
private def updateModeField : Nil
unless @mode == @lastMode
@tagFields[TAG_FIELD_MODE].set("State: ", (case @mode
in .playing?
"Playing"
in .paused?
"Paused"
in .playing_stop_after_current?
"Playing (stopping after current)"
in .paused_stop_after_current?
"Paused (stopping after current)"
in .undefined?
"Undefined"
end))
@tagFields[TAG_FIELD_MODE].redraw
@lastMode = @mode
end
end
###
### Progress Bar
###
# Updates the information that is on the same line as the progress bar.
private def updateProgressBarInfo : Nil
@progressBarLabel = if @maxLoops < 0
sprintf("[%c%c%c%c%c] %i/%i, %i of *",
@eqEnabled ? 'E' : 'e',
@stereoEnhancerEnabled ? 'S' : 's',
@softClippingEnabled ? 'C' : 'c',
@reverbEnabled ? 'R' : 'r',
@hasSongConfig ? '+' : '-',
@curTrack + 1u32,
@totalTracks,
@currentLoop + 1u32)
elsif @currentLoop > @maxLoops
sprintf("[%c%c%c%c%c] %i/%i, %i of %i",
@eqEnabled ? 'E' : 'e',
@stereoEnhancerEnabled ? 'S' : 's',
@softClippingEnabled ? 'C' : 'c',
@reverbEnabled ? 'R' : 'r',
@hasSongConfig ? '+' : '-',
@curTrack + 1u32,
@totalTracks,
@maxLoops + 1,
@maxLoops + 1)
else
sprintf("[%c%c%c%c%c] %i/%i, %i of %i",
@eqEnabled ? 'E' : 'e',
@stereoEnhancerEnabled ? 'S' : 's',
@softClippingEnabled ? 'C' : 'c',
@reverbEnabled ? 'R' : 'r',
@hasSongConfig ? '+' : '-',
@curTrack + 1u32,
@totalTracks,
@currentLoop + 1u32,
@maxLoops + 1)
end
@progressBarLabelWidth = @progressBarLabel.size
end
# This is based on the progress bar in RemiLib, but is leaner and more
# suited for music playback.
private def updateProgress : Nil
SLScreen.goto(0, @mainAreaStartRow + 7)
SLScreen.clearEol
conWidth = @lastWidth
return if conWidth < 10
width = conWidth.to_i32 - 1
# The -5 is to account for the percent display. The -2 is to account for
# the two vertical bars. The -1 is to account for a space between the
# label and bar.
#
# -3 to account for the [, ], and space for the time display.
barWidth = width - @progressBarLabelWidth - 5 - 2 - 1 -
@progressBarCurTime.size - @progressBarTotalTime.size - 3 - 1
# Write label
SLScreen.write("#{@progressBarLabel}: ")
# Write bar
percent : Float32 = Math.max(0.0f32, (@progressPos.to_f32! / @progressMax.to_f32!) * 100_f32)
percent = 100_f32 if percent > 100
progress : Int32 = Math.max(0, ((percent * barWidth) / 100_f32).to_i32!)
SLScreen.write('|')
OrigUI.theme.resetBarColor
progress.times do |i|
SLScreen.write(OrigUI.theme.pbarChar, color: OrigUI.theme.getBarColor(progress))
end
SLScreen.defaultColor
SLScreen.write("#{OrigUI.theme.pbarSpaceChar * (barWidth - progress)}|")
# Write percent
SLScreen.write("#{sprintf("%4i", percent.to_i)}%")
# Write time
SLScreen.write(" [#{@progressBarCurTime}/#{@progressBarTotalTime}]")
end
###
### VU meters
###
# Initializes the VU meters
protected def initVu : Nil
@vu = VUMeter.new(@mainAreaStartRow + 5, Benben.theme)
end
###
### Volume
###
# Updates the volume display.
private def updateVolume : Nil
xoffset : Int32 = @lastWidth - 10
yoffset : Int32 = @lastHeight - 1
SLScreen.write(xoffset, yoffset, sprintf("Vol: %.2f", @mainVolume))
@lastShownVolume = @mainVolume
end
###
### Volume
###
# Updates the CPU usage display.
@[AlwaysInline]
private def updateCpu : Nil
xoffset : Int32 = @lastWidth - 25
yoffset : Int32 = @lastHeight - 1
{% if flag?(:linux) %}
SLScreen.write(xoffset, yoffset, sprintf("%-12s", sprintf("CPU: %.1f%%", Utils.getCpu)))
{% else %}
SLScreen.write(xoffset, yoffset, "CPU: ?")
{% end %}
end
###
### Play Queue Time Handling
###
# Calculates the total time of the song queue, returning the length of time
# as a `Time::Span`. This method is thread safe.
private def calcTotalTime : Time::Span
@totalTimeMut.synchronize do
# Determine total time
newTotal = Time::Span.new
Benben.fileHandler.each do |file|
newTotal += file.timeLength
Fiber.yield
end
Benben.dlog!("Total play queue time calculated: #{newTotal}")
newTotal
end
end
# Gets the string that will display the total amount of time. The value is
# memoized, but if *force* is `true`, then it is always recalculated.
private def totalTimeStr(force : Bool = false) : String
if force || @totalTime.nil?
newTime = calcTotalTime
@totalTime = newTime
@totalTimeStr = if newTime.hours > 0
sprintf("Total time: %02i:%02i:%02i", newTime.total_hours, newTime.minutes, newTime.seconds)
else
sprintf("Total time: %02i:%02i", newTime.total_minutes, newTime.seconds)
end
end
@totalTimeStr
end
###
### Song Queue
###
@[AlwaysInline]
private def drawSongQueueName(offset : Int, name : String, isCurrent : Bool = false) : Nil
maxWidth = @lastWidth - 6
if isCurrent
SLScreen.write(1, offset, "> ")
else
SLScreen.write(1, offset, " ")
end
SLScreen.write(3, offset, Utils.clipField(name, maxWidth))
end
private def updateSongQueue : Nil
numTracks : Int32 = Benben.fileHandler.size
offset : Int32 = @mainAreaStartRow + 8
boxStart : Int32 = offset
maxWidth : Int32 = @lastWidth - 4
boxHeight : UInt32 = Math.max(@lastHeight - offset - 2, 6).to_u32
# Prepare the drawing area
OrigUI.theme.withSongQueueBoxColors do
SLScreen.drawBox(0, offset, @lastWidth.to_u32!, boxHeight)
SLScreen.fill(1, offset + 1, maxWidth.to_u32!, boxHeight - 2, ' ')
end
OrigUI.theme.withSongQueueHeaderColors do
SLScreen.write(2, offset, "Song Queue")
timeStr = totalTimeStr
SLScreen.write((@lastWidth - timeStr.size - 2), offset + boxHeight - 1, timeStr)
end
# Start drawing on the line immediately after the top of the box.
offset += 1
case Benben.fileHandler.size
when 0
# Consistency check
raise "How did Benben start?"
when 1
# Simple case: 1 track
curName : String = Benben.fileHandler.currentFileBasename
OrigUI.theme.startCurSongColor
drawSongQueueName(offset, curName, true)
else
# 2 or more tracks.
curName = Benben.fileHandler.currentFileBasename
if @curTrack == 0
# We're starting on the very first track in the queue.
OrigUI.theme.startCurSongColor
drawSongQueueName(offset, curName, true)
else
# We're past the first track of the queue, so draw the previous track,
# advance to the next line, then draw the current track.
otherTrack : String = Benben.fileHandler.getFileBasename(@curTrack - 1)
OrigUI.theme.startPrevSongColor
drawSongQueueName(offset, otherTrack)
offset += 1
OrigUI.theme.startCurSongColor
drawSongQueueName(offset, curName, true)
end
# Advance to the next line, then draw all remaining tracks.
offset += 1
trackNum : UInt32 = @curTrack + 1
colorNum : Int32 = 0
unless trackNum >= numTracks
(trackNum...numTracks).each do |idx|
OrigUI.theme.startNextSongColor(colorNum)
otherTrack = Benben.fileHandler.getFileBasename(idx)
drawSongQueueName(offset, otherTrack)
offset += 1
break if offset == (boxStart + boxHeight - 1)
if (colorNum += 1) == OrigUI.theme.numNextSongColors
colorNum -= 1
end
end
end
end
ensure
SLScreen.defaultColor
end
###
### State Updates
###
# Updates the internal state by getting information from the player and
# other places.
private def updateState : Nil
showUpdateMsgs : Bool = @initialUpdateFinished.lazyGet
updatePbarInfo : Bool = false
# Check to see if we've switched to a new song.
if !(newFile = Benben.player.curFile).same?(@playingFile)
# Update the tag information since we have a new song.
if @playingFile = newFile
@tag = newFile.taginfo
else
@tag = nil
end
updateTagFields
# Stop showing the help
@helpTimeout = nil
# Update the current track number.
newI32 = Benben.fileHandler.currentTrack.get
if newI32 >= 0
@curTrack = newI32.to_u32!
updateSongQueue
updatePbarInfo = true
end
# Reset the VU's clips
vu.resetClips
# Set mode to Playing
@lastMode = @mode
@mode = Mode::Playing
end
if (newProgressMax = Benben.player.totalSamples.get) != @progressMax
# Update the maximum value of the progress bar.
@progressMax = newProgressMax
ts = Time::Span.new(nanoseconds: (@progressMax * @invSampleRate).to_i64! * 1000000000)
@progressBarTotalTime = sprintf("%02i:%02i", ts.total_minutes, ts.seconds)
updatePbarInfo = true
end
# Maybe update the maximum play loops.
if (newI32 = Benben.player.maxLoops) != @maxLoops
if showUpdateMsgs
if newI32 > @maxLoops
# Loops increased
if @maxLoops < 0
queueMessage("Increased max loops, no longer looping indefinitely")
else
queueMessage("Increased max loops")
end
else
# Loops decreased
if newI32 < 0
queueMessage("Decreased max loops, now looping indefinitely")
else
queueMessage("Decreased max loops")
end
end
end
@maxLoops = newI32
updatePbarInfo = true
end
# Maybe update the current loop.
if (newU32 = Benben.player.currentLoop) != @currentLoop
@currentLoop = newU32
updatePbarInfo = true
end
# Maybe update the total number of tracks.
if (newU32 = Benben.fileHandler.size.to_u32) != @totalTracks
@totalTracks = newU32
updatePbarInfo = true
end
# Maybe update the progress bar's current position.
if (newU64 = Benben.player.samplesPlayed.get) != @progressPos
@progressPos = newU64
ts = Time::Span.new(nanoseconds: (@progressPos * @invSampleRate).to_i64! * 1000000000)
@progressBarCurTime = sprintf("%02i:%02i", ts.total_minutes, ts.seconds)
updatePbarInfo = true
end
# Maybe update whether or not the EQ is enabled.
if (newBool = Benben.player.eqEnabled?) != @eqEnabled
@eqEnabled = newBool
if showUpdateMsgs
queueMessage("Equalizer #{@eqEnabled ? "enabled" : "disabled"}")
end
updatePbarInfo = true
end
# Maybe update whether or not soft clipping is enabled.
if (newBool = Benben.player.softClippingEnabled?) != @softClippingEnabled
@softClippingEnabled = newBool
if showUpdateMsgs
queueMessage("Soft clipping #{@softClippingEnabled ? "enabled" : "disabled"}")
end
updatePbarInfo = true
end
# Maybe update whether or not the stereo enhancer is enabled.
if (newBool = Benben.player.stereoEnhancerEnabled?) != @stereoEnhancerEnabled
@stereoEnhancerEnabled = newBool
if showUpdateMsgs
queueMessage("Stereo enhancer #{@stereoEnhancerEnabled ? "enabled" : "disabled"}")
end
updatePbarInfo = true
end
# Maybe update whether or not the reverb is enabled.
if (newBool = Benben.player.reverbEnabled?) != @reverbEnabled
if showUpdateMsgs
if @reverbEnabled = newBool
queueMessage("Reverb enabled") #sprintf("Reverb enabled, amount: %.2f", @reverb.amount))
else
queueMessage("Reverb disabled")
end
end
updatePbarInfo = true
end
# KLUDGE workaround displaying the wrong volume until vol up/vol down is
# pressed.
updateVolume if @lastShownVolume != @mainVolume
# Maybe change the GD3 tag language that's shown.
if (newLang = Benben.config.vgm.preferredGd3Lang) && !newLang.togglable? && newLang != @gd3TagLang
Benben.dlog!("Manually toggling the GD3 language")
@gd3TagLang = newLang
updateTagDisplay
end
# Toggle the GD3 language if needed.
if Benben.config.vgm.preferredGd3Lang.togglable? &&
(Time.local - @lastTagSwap).total_seconds > GD3_TOGGLE_INTERVAL
toggleGD3Language
updateTagDisplay
@lastTagSwap = Time.local
end
# Update info line
if (newInfoLine = Benben.player.infoLine) && @infoLine != newInfoLine
@infoLine = newInfoLine
updateInfoLine
end
# If anything above wanted to update the progress bar display, do so now.
updateProgressBarInfo if updatePbarInfo
# This is always reset to true.
@initialUpdateFinished.set(true)
end
###
### Other Initializations
###
# Initializes the colors.
protected def initColors : Nil
if SLScreen.hasAnsiColors?
OrigUI.theme.apply
Banner.initColors
end
end
###
### Entry point and updating
###
# Updates the screen.
@[AlwaysInline]
protected def update : Nil
updateState
if @banner.done?
@banner.animateFadeDown
else
@banner.refreshBanner
end
@banner.refreshBanner unless @banner.done?
@banner.refreshBorderLines if @animations
vu.refresh
updateProgress
updateMessages
updateHelp
updateTagDisplay
updateModeField
if (Time.monotonic - @cpuLastUpdated).total_milliseconds >= CPU_USAGE_UPDATE_FREQ
updateCpu
@cpuLastUpdated = Time.monotonic
end
SLScreen.refresh
end
# Purposely unhygenic macro because Crystal has no macrolet
private macro doFps
Fiber.yield
sleep (FPS_CAP - (Time.monotonic - lastUpdate).total_milliseconds).milliseconds
lastUpdate = Time.monotonic
end
@[AlwaysInline]
private def drawVersionLine : Nil
SLScreen.write(0, @lastHeight - 1, "v#{VERSION} - Press 'q' to quit, 'h' for help")
end
def redrawWholeScreen : Nil
@helpTimeout = nil
LibSLang.SLsmg_init_smg()
RemiSlang::Screen.updateScreenSize
SLScreen.clear
updateTagFields
updateInfoLine
updateTagDisplay(true)
vu.refresh
updateVolume
updateCpu
updateProgressBarInfo
@banner.refreshBanner(true)
@banner.refreshBorderLines(true)
drawVersionLine
updateSongQueue
updateProgress
updateMessages
updateModeField
end
def queueRedrawWholeScreen : Nil
Benben.manager.sendToAllKeyChans(KeyCommand::ForceRedraw)
end
private def startInputLoop : Nil
Benben.dlog!("Input loop fiber is starting")
#STDIN.read_timeout = 500.milliseconds
spawn do
Benben.dlog!("Input loop is starting")
#keyBuf : Bytes = Bytes.new(1)
#haveEscape : Bool = false
#escaped : UInt8 = 0u8
until Benben.manager.state.get.quit?
begin
#STDIN.raw &.read(keyBuf)
# case keyBuf[0]
# when '\e'.ord
# haveEscape = true
# when 26 # Emulate Ctrl+Z
# haveEscape = false
# Benben.dlog!("Suspending")
# Process.signal(Signal::TSTP, Process.pid)
# when '['.ord
# if haveEscape
# escaped = keyBuf[0]
# else
# dispatchKey(keyBuf[0].chr)
# end
# else
# # Normal key, send it.
# if haveEscape
# dispatchKey(keyBuf[0].chr, escaped.chr)
# else
# dispatchKey(keyBuf[0].chr)
# end
# haveEscape = false
# end
# Remi: This code does not like Ctrl-Z. When this code is used
# and Ctrl-Z happens, then all further input requires the enter
# key to be pressed after each stroke. So don't use it.
#
if key = RemiSlang::Input.getKey?(-50)
case key.ord
when 26 # Emulate Ctrl+Z
Benben.dlog!("Suspending")
Process.signal(Signal::TSTP, Process.pid)
else
# Normal key, send it.
dispatchKey(key)
end
end
rescue IO::TimeoutError
nil
rescue err : Exception
Benben.dlog!("Input loop had an exception: #{err} (#{err.backtrace})")
end
Fiber.yield
end
begin
Benben.dlog!("Input loop has finished")
rescue err : Exception
Benben.manager.submitError(BenbenError.new("Fatal error in input loop while exiting: #{err}"))
end
end
end
private def dispatchKey(c : Char, escaped : Char? = nil) : Nil
{% begin %}
Benben.dlog!("Dispatching key: #{c} (#{c.ord}), escape: #{escaped} (#{escaped.try(&.ord)||0})")
case c
when ' ' then Benben.manager.sendToAllKeyChans(KeyCommand::Pause)
when 'n' then Benben.manager.sendToAllKeyChans(KeyCommand::Next)
when 'p' then Benben.manager.sendToAllKeyChans(KeyCommand::Previous)
when '>' then Benben.manager.sendToAllKeyChans(KeyCommand::SeekForward)
when '<' then Benben.manager.sendToAllKeyChans(KeyCommand::SeekBackward)
when 'a' then Benben.manager.sendToAllKeyChans(KeyCommand::VolUp)
when 'z' then Benben.manager.sendToAllKeyChans(KeyCommand::VolDown)
when 'e' then Benben.manager.sendToAllKeyChans(KeyCommand::EqToggle)
when 'c' then Benben.manager.sendToAllKeyChans(KeyCommand::SoftClipToggle)
when 's' then Benben.manager.sendToAllKeyChans(KeyCommand::StereoEnhanceToggle)
when 'r' then Benben.manager.sendToAllKeyChans(KeyCommand::ReverbToggle)
when 't' then Benben.manager.sendToAllKeyChans(KeyCommand::LangToggle)
when ']' then Benben.manager.sendToAllKeyChans(KeyCommand::LoopUp)
when '[' then Benben.manager.sendToAllKeyChans(KeyCommand::LoopDown)
when 'S' then Benben.manager.sendToAllKeyChans(KeyCommand::StopAfterCurrent)
when 'C' then Benben.manager.sendToAllKeyChans(KeyCommand::ReloadSongConfigs)
when 'i' then Benben.manager.sendToAllKeyChans(KeyCommand::ToggleInterpolation)
when 'x' then Benben.manager.sendToAllKeyChans(KeyCommand::ToggleChorus)
when 'R' then Benben.manager.sendToAllKeyChans(KeyCommand::ForceRedraw)
when 'q' then Benben.manager.sendToAllKeyChans(KeyCommand::Exit)
when 'h' then Benben.manager.sendToAllKeyChans(KeyCommand::Help)
{% if flag?(:benben_manual_gc) %}
when 'G' then GC.collect
{% end %}
end
{% end %}
end
# Runs the main UI loop.
def run : Nil
Benben.dlog!("Starting Original UI")
raise "Original UI started twice" if @running.lazyGet
@running.set(true)
lastUpdate : Time::Span = Time.monotonic
# Initialize S-Lang
@uiLock.synchronize do
# Initialize some basic things. Order here is important.
LibSLang.slang_tt_read_fd = STDIN.fd
Slang.init
SLScreen.clear
SLScreen.hideCursor
SLScreen.updateScreenSize
@lastWidth, @lastHeight = SLScreen.size
startInputLoop
initColors
@banner.initOffsets
initVu
drawVersionLine
# Immediately draw the banner if animations are disabled
@banner.refreshBanner(true) unless @animations
end
# Start the loop.
Benben.dlog!("Original UI main loop starting")
width : Int32 = 0
height : Int32 = 0
while @running.lazyGet
# Update the screen
@uiLock.synchronize do
width, height = SLScreen.size
if width < 80 || height < 24
SLScreen.clear
SLScreen.write(0, 0, "Terminal too small (#{width}x#{height}), 80x24 or larger needed")
SLScreen.refresh
doFps
next
elsif width != @lastWidth || height != @lastHeight
@lastWidth = width
@lastHeight = height
Benben.dlog!("Redrawing entire screen, new size is #{@lastWidth}x#{@lastHeight}")
redrawWholeScreen
end
update
end
# Check for commands.
select
when cmd = @keyChan.receive
case cmd
when .exit?
Benben.dlog!("Original UI has received an exit command")
@running.set(false)
when .help?
queueHelp
when .pause?
@lastMode = @mode
@mode = case @mode
when .playing? then Mode::Paused
when .playing_stop_after_current? then Mode::PausedStopAfterCurrent
when .paused? then Mode::Playing
when .paused_stop_after_current? then Mode::PlayingStopAfterCurrent
else Mode::Undefined
end
when .lang_toggle?
Benben.dlog!("UI is telling the settings to toggle the GD3 language")
Benben.config.vgm.preferredGd3LangToggle
when .vol_up?
newVol = (@mainVolume + VOL_STEP).clamp(MIN_VOL, MAX_VOL)
Benben.dlog!("Main volume changed from #{@mainVolume} to #{newVol}")
@mainVolume = newVol
vu.resetClips
updateVolume
when .vol_down?
newVol = (@mainVolume - VOL_STEP).clamp(MIN_VOL, MAX_VOL)
Benben.dlog!("Main volume changed from #{@mainVolume} to #{newVol}")
@mainVolume = newVol
vu.resetClips
updateVolume
when .reload_song_configs?
queueMessage("Reloading song configs")
Benben.config.loadSongConfigs
when .force_redraw?
Benben.dlog!("Redrawing entire screen due to request")
redrawWholeScreen
when .stop_after_current?
@stopAfterCurrent = !@stopAfterCurrent
if @initialUpdateFinished.get
if @stopAfterCurrent
queueMessage("Stopping after current song")
else
queueMessage("Not stopping after current song")
end
end
@lastMode = @mode
@mode = case @mode
when .playing? then Mode::PlayingStopAfterCurrent
when .paused? then Mode::PausedStopAfterCurrent
when .playing_stop_after_current? then Mode::Playing
when .paused_stop_after_current? then Mode::Paused
else Mode::Undefined
end
else Benben.dlog!("Original UI is ignoring command: #{cmd}")
end # case cmd
else nil
end # select
# Sleep
doFps
end
Benben.dlog!("Original UI has quit")
end
# Shuts down the UI.
def deinit : Nil
Benben.dlog!("Running Original UI deinit")
@running.set(false)
Slang.deinit
Benben.dlog!("Original UI has been deinitialized")
end
end
end