From e776bc768cf9afca1867200e25d64d315cd72a3e Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Fri, 15 May 2026 10:10:04 -0400 Subject: Full mixer implementation — tray, popup, prefs, devices tab with port indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the complete tmix feature set built since the initial skeleton: balance knob, level meters, KLed mute button, system tray with scroll-wheel volume and recording indicator, tray popup, preferences dialog, right-click context menus, single-instance enforcement, scroll area, window geometry persistence, and Devices tab with PA card profile switcher and live port availability indicators (2-column layout, in-place updates on plug/unplug). Co-Authored-By: Claude Sonnet 4.6 --- src/ui/balanceknob.cpp | 124 +++++++++++++++ src/ui/balanceknob.h | 34 ++++ src/ui/devicespage.cpp | 251 ++++++++++++++++++++++++++++++ src/ui/devicespage.h | 42 +++++ src/ui/devicewidget.cpp | 384 +++++++++++++++++++++++++++++++++++++++++----- src/ui/devicewidget.h | 55 +++++-- src/ui/kledbutton.cpp | 29 ++++ src/ui/kledbutton.h | 24 +++ src/ui/levelmeter.cpp | 67 ++++++++ src/ui/levelmeter.h | 17 ++ src/ui/mixerwindow.cpp | 381 ++++++++++++++++++++++++++++++++++++++++++--- src/ui/mixerwindow.h | 51 ++++-- src/ui/preferencesdlg.cpp | 111 ++++++++++++++ src/ui/preferencesdlg.h | 37 +++++ src/ui/tmixapp.cpp | 20 +++ src/ui/tmixapp.h | 17 ++ src/ui/tmixpopup.cpp | 134 ++++++++++++++++ src/ui/tmixpopup.h | 42 +++++ src/ui/tmixtray.cpp | 182 ++++++++++++++++++++++ src/ui/tmixtray.h | 36 +++++ 20 files changed, 1961 insertions(+), 77 deletions(-) create mode 100644 src/ui/balanceknob.cpp create mode 100644 src/ui/balanceknob.h create mode 100644 src/ui/devicespage.cpp create mode 100644 src/ui/devicespage.h create mode 100644 src/ui/kledbutton.cpp create mode 100644 src/ui/kledbutton.h create mode 100644 src/ui/levelmeter.cpp create mode 100644 src/ui/levelmeter.h create mode 100644 src/ui/preferencesdlg.cpp create mode 100644 src/ui/preferencesdlg.h create mode 100644 src/ui/tmixapp.cpp create mode 100644 src/ui/tmixapp.h create mode 100644 src/ui/tmixpopup.cpp create mode 100644 src/ui/tmixpopup.h create mode 100644 src/ui/tmixtray.cpp create mode 100644 src/ui/tmixtray.h (limited to 'src/ui') diff --git a/src/ui/balanceknob.cpp b/src/ui/balanceknob.cpp new file mode 100644 index 0000000..1c2faa4 --- /dev/null +++ b/src/ui/balanceknob.cpp @@ -0,0 +1,124 @@ +#include "balanceknob.h" + +#include +#include +#include +#include +#include + +static const double DEG2RAD = M_PI / 180.0; +static const double TRAVEL = 135.0; // degrees each side from centre + +BalanceKnob::BalanceKnob( int minVal, int maxVal, int val, TQWidget *parent ) + : TQWidget(parent), + m_min(minVal), m_max(maxVal), m_value(val), + m_dragY(0), m_dragValue(0), m_dragging(false) +{ + setFixedSize( 30, 30 ); + setBackgroundMode( TQt::NoBackground ); +} + +void BalanceKnob::setValue( int v ) +{ + v = v < m_min ? m_min : v > m_max ? m_max : v; + if ( v == m_value ) return; + m_value = v; + update(); + emit valueChanged( v ); +} + +void BalanceKnob::paintEvent( TQPaintEvent * ) +{ + // 2× supersampling for smooth edges (TQt3 has no native AA) + const int S = 2; + int W = width() * S, H = height() * S; + int cx = W / 2, cy = H / 2; + int r = ( W < H ? W : H ) / 2 - S; + + TQPixmap pm( W, H ); + pm.fill( paletteBackgroundColor() ); + TQPainter p( &pm ); + + double mid = ( m_min + m_max ) / 2.0; + double half = ( m_max - m_min ) / 2.0; + + // ---- range track arc (270° from 7:30 clockwise to 4:30) ----------------- + p.setPen( TQPen( TQColor(90, 90, 90), S * 2 ) ); + p.setBrush( TQt::NoBrush ); + p.drawArc( cx - r, cy - r, 2*r, 2*r, 225*16, -270*16 ); + + // ---- value arc (from 12-o'clock to current position) -------------------- + double current_qt = 90.0 - ( (m_value - mid) / half ) * TRAVEL; + int spanAngle = (int)( (current_qt - 90.0) * 16.0 ); + if ( spanAngle != 0 ) { + p.setPen( TQPen( TQColor(90, 140, 220), S * 2 ) ); + p.drawArc( cx - r, cy - r, 2*r, 2*r, 90*16, spanAngle ); + } + + // ---- knob body ----------------------------------------------------------- + int kr = r - S * 3; + p.setPen( TQt::NoPen ); + + p.setBrush( TQColor(38, 38, 42) ); + p.drawEllipse( cx - kr, cy - kr, 2*kr, 2*kr ); + + p.setBrush( TQColor(68, 68, 74) ); + p.drawEllipse( cx - kr + S, cy - kr + S, 2*kr - S*3, 2*kr - S*3 ); + + // ---- centre rest dot ---------------------------------------------------- + p.setPen( TQt::NoPen ); + p.setBrush( TQColor(100, 100, 110) ); + p.drawEllipse( cx - S, cy - kr + S*2, S*2, S*2 ); + + // ---- indicator line ----------------------------------------------------- + double angle_rad = ( (m_value - mid) / half ) * TRAVEL * DEG2RAD; + int ix = cx + (int)( (kr - S*3) * sin( angle_rad ) ); + int iy = cy - (int)( (kr - S*3) * cos( angle_rad ) ); + + p.setPen( TQPen( TQt::white, S * 2 ) ); + p.drawLine( cx, cy, ix, iy ); + + p.end(); + + // Scale back down and blit + TQImage scaled = pm.convertToImage().smoothScale( width(), height() ); + TQPainter out( this ); + out.drawImage( 0, 0, scaled ); +} + +void BalanceKnob::mousePressEvent( TQMouseEvent *e ) +{ + if ( e->button() == TQt::LeftButton ) { + m_dragY = e->globalPos().y(); + m_dragValue = m_value; + m_dragging = true; + grabMouse(); + } +} + +void BalanceKnob::mouseMoveEvent( TQMouseEvent *e ) +{ + if ( !m_dragging ) return; + int delta = m_dragY - e->globalPos().y(); // drag up = increase + setValue( m_dragValue + delta ); +} + +void BalanceKnob::mouseReleaseEvent( TQMouseEvent * ) +{ + if ( m_dragging ) { + m_dragging = false; + releaseMouse(); + } +} + +void BalanceKnob::mouseDoubleClickEvent( TQMouseEvent * ) +{ + setValue( (int)( (m_min + m_max) / 2.0 ) ); +} + +void BalanceKnob::wheelEvent( TQWheelEvent *e ) +{ + setValue( m_value + ( e->delta() > 0 ? 5 : -5 ) ); +} + +#include "balanceknob.moc" diff --git a/src/ui/balanceknob.h b/src/ui/balanceknob.h new file mode 100644 index 0000000..e8d0be2 --- /dev/null +++ b/src/ui/balanceknob.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +class BalanceKnob : public TQWidget +{ + TQ_OBJECT + +public: + BalanceKnob( int minVal, int maxVal, int val, TQWidget *parent = 0 ); + + int value() const { return m_value; } + TQSize sizeHint() const { return TQSize(30, 30); } + +public slots: + void setValue( int v ); + +signals: + void valueChanged( int v ); + +protected: + void paintEvent( TQPaintEvent * ); + void mousePressEvent( TQMouseEvent *e ); + void mouseMoveEvent( TQMouseEvent *e ); + void mouseReleaseEvent( TQMouseEvent *e ); + void mouseDoubleClickEvent( TQMouseEvent *e ); + void wheelEvent( TQWheelEvent *e ); + +private: + int m_min, m_max, m_value; + int m_dragY; + int m_dragValue; + bool m_dragging; +}; diff --git a/src/ui/devicespage.cpp b/src/ui/devicespage.cpp new file mode 100644 index 0000000..28cce07 --- /dev/null +++ b/src/ui/devicespage.cpp @@ -0,0 +1,251 @@ +#include "devicespage.h" +#include "../model/pulsemodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +static TQString portTypeIcon( uint32_t type ) +{ + switch ( type ) { + case PA_DEVICE_PORT_TYPE_HEADPHONES: return "audio-headphones"; + case PA_DEVICE_PORT_TYPE_HEADSET: return "audio-headset"; + case PA_DEVICE_PORT_TYPE_MIC: return "audio-input-microphone"; + case PA_DEVICE_PORT_TYPE_SPEAKER: return "audio-speakers"; + case PA_DEVICE_PORT_TYPE_HDMI: return "video-display"; + case PA_DEVICE_PORT_TYPE_SPDIF: return "audio-card"; + case PA_DEVICE_PORT_TYPE_LINE: return "audio-card"; + case PA_DEVICE_PORT_TYPE_USB: return "audio-card"; + case PA_DEVICE_PORT_TYPE_BLUETOOTH: return "bluetooth"; + default: return "audio-card"; + } +} + +static TQPixmap availDot( bool plugged, int size ) +{ + TQPixmap pm( size, size ); + TQBitmap mask( size, size, true ); + { + TQPainter mp( &mask ); + mp.setBrush( Qt::color1 ); + mp.setPen( Qt::NoPen ); + mp.drawEllipse( 1, 1, size-2, size-2 ); + } + pm.setMask( mask ); + pm.fill( plugged ? TQColor(80, 200, 80) : TQColor(160, 160, 160) ); + { + TQPainter p( &pm ); + p.setBrush( plugged ? TQColor(80, 200, 80) : TQColor(160, 160, 160) ); + p.setPen( Qt::NoPen ); + p.drawEllipse( 1, 1, size-2, size-2 ); + } + return pm; +} + +DevicesPage::DevicesPage( PulseModel *model, TQWidget *parent ) + : TQWidget(parent), m_model(model), m_container(0) +{ + TQVBoxLayout *outer = new TQVBoxLayout( this, 0, 0 ); + m_scroll = new TQScrollView( this ); + m_scroll->setFrameStyle( TQFrame::NoFrame ); + m_scroll->setResizePolicy( TQScrollView::AutoOneFit ); + outer->addWidget( m_scroll ); + + rebuild(); + + connect( model, TQ_SIGNAL(cardAdded(uint32_t)), this, TQ_SLOT(onCardAdded(uint32_t)) ); + connect( model, TQ_SIGNAL(cardRemoved(uint32_t)), this, TQ_SLOT(onCardRemoved(uint32_t)) ); + connect( model, TQ_SIGNAL(cardUpdated(uint32_t)), this, TQ_SLOT(onCardUpdated(uint32_t)) ); +} + +void DevicesPage::rebuild() +{ + m_comboCard.clear(); + m_profileNames.clear(); + m_cardCombo.clear(); + m_cardPorts.clear(); + + delete m_container; + m_container = new TQWidget( m_scroll->viewport() ); + m_scroll->addChild( m_container ); + + TQVBoxLayout *vbox = new TQVBoxLayout( m_container, 8, 6 ); + + TQValueList cards = m_model->cards(); + for ( TQValueList::Iterator it = cards.begin(); it != cards.end(); ++it ) { + PulseCardInfo &info = *it; + + TQGroupBox *grp = new TQGroupBox( info.description, m_container ); + grp->setColumnLayout( 0, Qt::Vertical ); + grp->layout()->setSpacing( 4 ); + grp->layout()->setMargin( 8 ); + vbox->addWidget( grp ); + + TQGridLayout *grid = new TQGridLayout( grp->layout(), 0, 2, 4 ); + grid->setColStretch( 1, 1 ); + int gridRow = 0; + + auto addRow = [&]( const TQString &labelText, TQWidget *valueWidget ) { + TQLabel *lbl = new TQLabel( labelText, grp ); + lbl->setAlignment( TQt::AlignRight | TQt::AlignVCenter ); + grid->addWidget( lbl, gridRow, 0 ); + grid->addWidget( valueWidget, gridRow, 1 ); + ++gridRow; + }; + auto addTextRow = [&]( const TQString &labelText, const TQString &valueText ) { + addRow( labelText, new TQLabel( valueText, grp ) ); + }; + + if ( !info.vendor.isEmpty() ) + addTextRow( i18n("Vendor:"), info.vendor ); + if ( !info.product.isEmpty() ) + addTextRow( i18n("Product:"), info.product ); + TQStringList typeparts; + if ( !info.formFactor.isEmpty() ) typeparts << info.formFactor; + if ( !info.busType.isEmpty() ) typeparts << info.busType; + if ( !typeparts.isEmpty() ) + addTextRow( i18n("Type:"), typeparts.join(" / ") ); + + TQComboBox *combo = new TQComboBox( false, grp ); + combo->setSizePolicy( TQSizePolicy::Preferred, TQSizePolicy::Fixed ); + addRow( i18n("Profile:"), combo ); + + TQValueList names; + int activeIdx = 0, i = 0; + for ( TQValueList::Iterator pit = info.profiles.begin(); + pit != info.profiles.end(); ++pit, ++i ) { + combo->insertItem( pit->description ); + names.append( pit->name ); + if ( pit->name == info.activeProfile ) + activeIdx = i; + } + if ( info.profiles.count() > 0 ) + combo->setCurrentItem( activeIdx ); + + m_profileNames[combo] = names; + m_comboCard[combo] = info.index; + m_cardCombo[info.index] = combo; + + connect( combo, TQ_SIGNAL(activated(int)), this, TQ_SLOT(onProfileActivated(int)) ); + + // Ports — 2-column grid to keep tall cards compact + if ( !info.ports.isEmpty() ) { + TQLabel *portsHdr = new TQLabel( i18n("Ports:"), grp ); + portsHdr->setAlignment( TQt::AlignRight | TQt::AlignTop ); + TQWidget *portsWidget = new TQWidget( grp ); + TQGridLayout *portsGrid = new TQGridLayout( portsWidget, 0, 2, 0, 4 ); + portsGrid->setColStretch( 0, 1 ); + portsGrid->setColStretch( 1, 1 ); + + TQValueList portWidgetList; + int portIdx = 0; + for ( TQValueList::Iterator pit = info.ports.begin(); + pit != info.ports.end(); ++pit, ++portIdx ) { + bool plugged = ( pit->available != PA_PORT_AVAILABLE_NO ); + + TQWidget *row = new TQWidget( portsWidget ); + TQHBoxLayout *hbox = new TQHBoxLayout( row, 0, 4 ); + + TQLabel *dot = new TQLabel( row ); + dot->setPixmap( availDot( plugged, 10 ) ); + dot->setFixedSize( 10, 10 ); + + TQLabel *icon = new TQLabel( row ); + TQPixmap px = TDEGlobal::iconLoader()->loadIcon( + portTypeIcon( pit->type ), TDEIcon::Small, 16, + TDEIcon::DefaultState, 0, true ); + if ( !px.isNull() ) + icon->setPixmap( px ); + icon->setFixedSize( 16, 16 ); + + TQLabel *name = new TQLabel( pit->description, row ); + + hbox->addWidget( dot ); + hbox->addWidget( icon ); + hbox->addWidget( name ); + hbox->addStretch( 1 ); + + portsGrid->addWidget( row, portIdx / 2, portIdx % 2 ); + + PortWidgets pw; + pw.dot = dot; + portWidgetList.append( pw ); + } + m_cardPorts[info.index] = portWidgetList; + + grid->addWidget( portsHdr, gridRow, 0, TQt::AlignRight | TQt::AlignTop ); + grid->addWidget( portsWidget, gridRow, 1 ); + ++gridRow; + } + } + + vbox->addStretch( 1 ); + m_container->show(); +} + +void DevicesPage::onCardAdded( uint32_t ) { rebuild(); } +void DevicesPage::onCardRemoved( uint32_t ) { rebuild(); } + +void DevicesPage::onCardUpdated( uint32_t index ) +{ + TQMap::Iterator cit = m_cardCombo.find( index ); + if ( cit == m_cardCombo.end() ) return; + TQComboBox *combo = cit.data(); + + const PulseCardInfo *info = m_model->card( index ); + if ( !info ) return; + + // Update active profile in combo + TQMap >::Iterator nit = m_profileNames.find( combo ); + if ( nit != m_profileNames.end() ) { + TQValueList &names = nit.data(); + for ( int i = 0; i < (int)names.count(); ++i ) { + if ( names[i] == info->activeProfile ) { + combo->blockSignals( true ); + combo->setCurrentItem( i ); + combo->blockSignals( false ); + break; + } + } + } + + // Update port availability indicators in-place + TQMap >::Iterator pit = m_cardPorts.find( index ); + if ( pit == m_cardPorts.end() ) return; + TQValueList &portWidgets = pit.data(); + + int i = 0; + for ( TQValueList::ConstIterator p = info->ports.begin(); + p != info->ports.end() && i < (int)portWidgets.count(); ++p, ++i ) { + bool plugged = ( (*p).available != PA_PORT_AVAILABLE_NO ); + portWidgets[i].dot->setPixmap( availDot( plugged, 10 ) ); + } +} + +void DevicesPage::onProfileActivated( int idx ) +{ + TQComboBox *combo = dynamic_cast( const_cast( sender() ) ); + if ( !combo ) return; + + TQMap::Iterator cit = m_comboCard.find( combo ); + if ( cit == m_comboCard.end() ) return; + uint32_t cardIndex = cit.data(); + + TQMap >::Iterator nit = m_profileNames.find( combo ); + if ( nit == m_profileNames.end() || idx >= (int)nit.data().count() ) return; + + m_model->setCardProfile( cardIndex, nit.data()[idx] ); +} + +#include "devicespage.moc" diff --git a/src/ui/devicespage.h b/src/ui/devicespage.h new file mode 100644 index 0000000..49081f4 --- /dev/null +++ b/src/ui/devicespage.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +class PulseModel; +class TQScrollView; +class TQComboBox; +class TQLabel; +class TQVBoxLayout; + +struct PortWidgets { + TQLabel *dot; +}; + +class DevicesPage : public TQWidget +{ + TQ_OBJECT + +public: + explicit DevicesPage( PulseModel *model, TQWidget *parent = 0 ); + +private slots: + void onCardAdded( uint32_t index ); + void onCardRemoved( uint32_t index ); + void onCardUpdated( uint32_t index ); + void onProfileActivated( int comboIndex ); + +private: + void rebuild(); + + PulseModel *m_model; + TQScrollView *m_scroll; + TQWidget *m_container; + + TQMap m_comboCard; + TQMap > m_profileNames; + TQMap m_cardCombo; + TQMap > m_cardPorts; +}; diff --git a/src/ui/devicewidget.cpp b/src/ui/devicewidget.cpp index 420d5fd..e6401d2 100644 --- a/src/ui/devicewidget.cpp +++ b/src/ui/devicewidget.cpp @@ -1,69 +1,383 @@ #include "devicewidget.h" +#include "kledbutton.h" +#include "levelmeter.h" #include "../model/audiodevice.h" +#include "../model/pulsedevice.h" +#include "../model/pulsemodel.h" -#include #include #include -#include +#include +#include +#include +#include +#include +#include "balanceknob.h" +#include +#include +#include +#include +#include +#include +#include + +static TQPixmap kmixPic( const char *name ) +{ + TQString path = TDEGlobal::dirs()->findResource( "data", + TQString("tmix/pics/") + name ); + return path.isEmpty() ? TQPixmap() : TQPixmap( path ); +} + +static TQPixmap kmixIconForDevice( const TQString &paIconName, AudioDevice::Category cat ) +{ + // Input devices always get the mic icon — overrides device name hints. + if ( cat == AudioDevice::Input ) + return kmixPic("mix_microphone.png"); + + // Map PA device icon name keywords to KMix pics before falling back to category. + TQString n = paIconName.lower(); + if ( n.contains("headset") || n.contains("headphone") ) + return kmixPic("mix_headphone.png"); + if ( n.contains("microphone") || n.contains("mic") ) + return kmixPic("mix_microphone.png"); + if ( n.contains("digital") || n.contains("hdmi") || n.contains("iec") ) + return kmixPic("mix_digital.png"); + if ( n.contains("surround") ) + return kmixPic("mix_surround.png"); + if ( n.contains("cd") ) + return kmixPic("mix_cd.png"); + if ( n.contains("midi") ) + return kmixPic("mix_midi.png"); + if ( n.contains("video") ) + return kmixPic("mix_video.png"); + + // Category fallback + switch ( cat ) { + case AudioDevice::Output: return kmixPic("mix_volume.png"); + case AudioDevice::Input: return kmixPic("mix_microphone.png"); + case AudioDevice::Playback: return kmixPic("mix_audio.png"); + case AudioDevice::Recording: return kmixPic("mix_record.png"); + } + return TQPixmap(); +} + +// Rotated label — text reads bottom-to-top. Mirrors KMix VerticalText. +class VerticalLabel : public TQWidget +{ +public: + VerticalLabel( const TQString &text, TQWidget *parent ) + : TQWidget(parent, text.utf8()), m_text(text) + { + resize( 20, 100 ); + setMinimumSize( 20, 10 ); + } + + void setText( const TQString &t ) { m_text = t; update(); } + + TQSize sizeHint() const { return TQSize( 20, 100 ); } + + TQSizePolicy sizePolicy() const + { + return TQSizePolicy( TQSizePolicy::Fixed, TQSizePolicy::Expanding ); + } + +protected: + void paintEvent( TQPaintEvent * ) + { + TQPainter p( this ); + TQFontMetrics fm = p.fontMetrics(); + int available = height() - 6; + TQString text = m_text; + if ( fm.width(text) > available ) { + while ( text.length() > 1 && fm.width(text) + fm.width("...") > available ) + text.truncate( text.length() - 1 ); + text += "..."; + } + p.rotate( 270 ); + p.translate( 0, -4 ); + p.drawText( -height() + 2, width(), text ); + } + +private: + TQString m_text; +}; + -DeviceWidget::DeviceWidget( AudioDevice *device, TQWidget *parent ) - : TQWidget(parent), m_device(device) +DeviceWidget::DeviceWidget( AudioDevice *device, PulseModel *model, TQWidget *parent ) + : TQWidget(parent), m_device(device), m_model(model), + m_sep(0), m_recordingLed(0), m_defaultBtn(0) { - TQVBoxLayout *layout = new TQVBoxLayout( this, 4, 2 ); + // ---- widgets ------------------------------------------------------------- + m_label = new VerticalLabel( device->name(), this ); - m_label = new TQLabel( device->name(), this ); - m_label->setAlignment( TQLabel::AlignHCenter ); + // App / device icon — 22px, loaded from TDE icon theme with KMix pic fallbacks. + m_iconLabel = new TQLabel( this ); + m_iconLabel->setFixedSize( 22, 22 ); + m_iconLabel->setAlignment( TQLabel::AlignCenter ); + { + TQString icon = device->iconName(); + TQPixmap px; + if ( !icon.isEmpty() ) + px = TDEGlobal::iconLoader()->loadIcon( + icon, TDEIcon::Small, 22, + TDEIcon::DefaultState, 0, true /*canReturnNull*/ ); + if ( px.isNull() ) + px = kmixIconForDevice( icon, device->category() ); + if ( !px.isNull() ) + m_iconLabel->setPixmap( px ); + } - m_slider = new TQSlider( 0, 100, 5, device->volume(), TQt::Vertical, this ); - m_slider->setTickmarks( TQSlider::NoMarks ); + m_volLabel = new TQLabel( TQString::number(device->volume()) + "%", this ); + m_volLabel->setAlignment( TQLabel::AlignHCenter ); - m_muteButton = new TQToolButton( this ); - m_muteButton->setToggleButton( true ); - m_muteButton->setTextLabel( "M" ); - m_muteButton->setOn( device->muted() ); + m_levelMeter = new LevelMeter( this ); + m_levelMeter->setFixedWidth( 14 ); - layout->addWidget( m_label ); - layout->addWidget( m_slider, 1 ); - layout->addWidget( m_muteButton, 0, TQt::AlignHCenter ); + m_volSlider = new TQSlider( 0, 100, 5, 100 - device->volume(), TQt::Vertical, this ); + m_volSlider->setTickmarks( TQSlider::Right ); + m_volSlider->setTickInterval( 5 ); - // slider → device - connect( m_slider, TQ_SIGNAL(valueChanged(int)), this, TQ_SLOT(onVolumeChanged(int)) ); - connect( m_muteButton, TQ_SIGNAL(clicked()), this, TQ_SLOT(onMuteToggled()) ); + // ---- layout -------------------------------------------------------------- - // device → widget - connect( device, TQ_SIGNAL(volumeChanged(int)), this, TQ_SLOT(onDeviceVolume(int)) ); - connect( device, TQ_SIGNAL(muteChanged(bool)), this, TQ_SLOT(onDeviceMute(bool)) ); + // top row: icon + vol% — centered over the meter+slider column (not the label) + TQHBoxLayout *topRow = new TQHBoxLayout( 0, 0, 2 ); + topRow->addStretch( 1 ); + topRow->addWidget( m_iconLabel, 0, TQt::AlignVCenter ); + topRow->addSpacing( 3 ); + topRow->addWidget( m_volLabel, 0, TQt::AlignVCenter ); + topRow->addStretch( 1 ); + + // meter + slider column, with topRow sitting above them + TQHBoxLayout *meterSlider = new TQHBoxLayout( 0, 0, 2 ); + meterSlider->addWidget( m_levelMeter ); + meterSlider->addWidget( m_volSlider, 1 ); + + TQVBoxLayout *sliderCol = new TQVBoxLayout( 0, 0, 2 ); + sliderCol->addLayout( topRow, 0 ); + sliderCol->addLayout( meterSlider, 1 ); + + // strip: vertical label beside the slider column + TQHBoxLayout *strip = new TQHBoxLayout( 0, 0, 2 ); + strip->addWidget( m_label ); + strip->addLayout( sliderCol, 1 ); + + // bottom row — fixed 30px height so all widgets align regardless of content. + // Created before its child widgets so they can be parented directly to it. + TQWidget *bottomWidget = new TQWidget( this ); + bottomWidget->setFixedHeight( 30 ); + TQHBoxLayout *bottom = new TQHBoxLayout( bottomWidget, 0, 2 ); + + // Balance knob — not meaningful for Input (just attenuates capture channels) + if ( device->category() != AudioDevice::Input ) { + m_balanceDial = new BalanceKnob( -50, 50, device->pan(), bottomWidget ); + TQToolTip::add( m_balanceDial, i18n("Balance (drag or scroll; double-click to center)") ); + } else { + m_balanceDial = 0; + } + + // Red recording-active LED — Input only, shows when a stream is capturing + if ( device->category() == AudioDevice::Input ) { + m_recordingLed = new KLed( TQt::red, KLed::Off, KLed::Raised, KLed::Circular, bottomWidget ); + m_recordingLed->setFixedSize( 16, 16 ); + TQToolTip::add( m_recordingLed, i18n("Microphone in use") ); + connect( device, TQ_SIGNAL(recordingActiveChanged(bool)), + this, TQ_SLOT(onRecordingActive(bool)) ); + } + + // Green LED = live, off = muted. Click to toggle. + m_muteLed = new KLedButton( + TQt::green, + device->muted() ? KLed::Off : KLed::On, + KLed::Raised, KLed::Circular, bottomWidget ); + m_muteLed->setFixedSize( 16, 16 ); + TQToolTip::add( m_muteLed, i18n("Mute") ); + + // Radio button = this device is the system default. Only for Output/Input. + AudioDevice::Category cat = device->category(); + if ( model && ( cat == AudioDevice::Output || cat == AudioDevice::Input ) ) { + m_defaultBtn = new TQRadioButton( TQString(), bottomWidget ); + TQToolTip::add( m_defaultBtn, i18n("Set as default") ); + connect( m_defaultBtn, TQ_SIGNAL(clicked()), this, TQ_SLOT(onDefaultClicked()) ); + + if ( cat == AudioDevice::Output ) { + connect( model, TQ_SIGNAL(defaultOutputChanged(AudioDevice*)), + this, TQ_SLOT(onDefaultChanged(AudioDevice*)) ); + onDefaultChanged( model->defaultOutput() ); + } else { + connect( model, TQ_SIGNAL(defaultInputChanged(AudioDevice*)), + this, TQ_SLOT(onDefaultChanged(AudioDevice*)) ); + onDefaultChanged( model->defaultInput() ); + } + } + bottom->addStretch( 1 ); + if ( m_defaultBtn ) + bottom->addWidget( m_defaultBtn, 0, TQt::AlignVCenter ); + bottom->addStretch( 1 ); + if ( m_balanceDial ) + bottom->addWidget( m_balanceDial, 0, TQt::AlignVCenter ); + else if ( m_recordingLed ) + bottom->addWidget( m_recordingLed, 0, TQt::AlignVCenter ); + bottom->addStretch( 1 ); + bottom->addWidget( m_muteLed, 0, TQt::AlignVCenter ); + bottom->addStretch( 1 ); + + // outer column: strip (label+sliderCol) | bottom row + TQVBoxLayout *right = new TQVBoxLayout( 0, 0, 2 ); + right->addLayout( strip, 1 ); + right->addWidget( bottomWidget, 0 ); + + // separator on right edge + m_sep = new TQFrame( this ); + m_sep->setFrameStyle( TQFrame::VLine | TQFrame::Sunken ); + m_sep->setFixedWidth( 4 ); + + TQHBoxLayout *outer = new TQHBoxLayout( this, 4, 2 ); + outer->addLayout( right, 1 ); + outer->addWidget( m_sep, 0, TQt::AlignVCenter ); + + // ---- connections --------------------------------------------------------- + connect( m_volSlider, TQ_SIGNAL(valueChanged(int)), this, TQ_SLOT(onVolumeChanged(int)) ); + if ( m_balanceDial ) + connect( m_balanceDial, TQ_SIGNAL(valueChanged(int)), this, TQ_SLOT(onBalanceChanged(int)) ); + connect( m_muteLed, TQ_SIGNAL(stateChanged(bool)), this, TQ_SLOT(onMuteClicked()) ); + + connect( device, TQ_SIGNAL(volumeChanged(int)), this, TQ_SLOT(onDeviceVolume(int)) ); + connect( device, TQ_SIGNAL(muteChanged(bool)), this, TQ_SLOT(onDeviceMute(bool)) ); + connect( device, TQ_SIGNAL(panChanged(int)), this, TQ_SLOT(onDevicePan(int)) ); + connect( device, TQ_SIGNAL(levelChanged(float)), this, TQ_SLOT(onDeviceLevel(float)) ); connect( device, TQ_SIGNAL(nameChanged(const TQString&)), this, TQ_SLOT(onDeviceName(const TQString&)) ); } void DeviceWidget::onVolumeChanged( int v ) { - m_device->setVolume( v ); + int pct = 100 - v; + m_volLabel->setText( TQString::number(pct) + "%" ); + m_device->setVolume( pct ); } -void DeviceWidget::onMuteToggled() -{ - m_device->setMuted( !m_device->muted() ); -} +void DeviceWidget::onBalanceChanged( int v ) { m_device->setPan( v ); } +void DeviceWidget::onMuteClicked() { m_device->setMuted( m_muteLed->state() == KLed::Off ); } void DeviceWidget::onDeviceVolume( int v ) { - // Block signal to avoid feedback loop back to device. - m_slider->blockSignals( true ); - m_slider->setValue( v ); - m_slider->blockSignals( false ); + m_volSlider->blockSignals( true ); + m_volSlider->setValue( 100 - v ); + m_volSlider->blockSignals( false ); + m_volLabel->setText( TQString::number(v) + "%" ); } void DeviceWidget::onDeviceMute( bool m ) { - m_muteButton->blockSignals( true ); - m_muteButton->setOn( m ); - m_muteButton->blockSignals( false ); + m_muteLed->blockSignals( true ); + m_muteLed->setState( m ? KLed::Off : KLed::On ); + m_muteLed->blockSignals( false ); +} + +void DeviceWidget::onDevicePan( int p ) +{ + m_balanceDial->blockSignals( true ); + m_balanceDial->setValue( p ); + m_balanceDial->blockSignals( false ); } +void DeviceWidget::onDeviceLevel( float level ) { m_levelMeter->setLevel( level ); } + void DeviceWidget::onDeviceName( const TQString &name ) { - m_label->setText( name ); + static_cast(m_label)->setText( name ); +} + +void DeviceWidget::contextMenuEvent( TQContextMenuEvent *e ) +{ + TDEPopupMenu menu( this ); + menu.insertTitle( m_device->name() ); + + int muteId = menu.insertItem( i18n("Mute"), this, TQ_SLOT(onToggleMute()) ); + menu.setItemChecked( muteId, m_device->muted() ); + + if ( m_model ) { + AudioDevice::Category cat = m_device->category(); + + if ( cat == AudioDevice::Output ) { + menu.insertSeparator(); + menu.insertItem( i18n("Set as Default Output"), this, TQ_SLOT(onSetDefault()) ); + + } else if ( cat == AudioDevice::Input ) { + menu.insertSeparator(); + menu.insertItem( i18n("Set as Default Input"), this, TQ_SLOT(onSetDefault()) ); + + } else if ( cat == AudioDevice::Playback ) { + TQPtrList sinks = m_model->devices( AudioDevice::Output ); + if ( !sinks.isEmpty() ) { + menu.insertTitle( i18n("Move to Sink") ); + m_moveTargets.clear(); + + PulseDevice *pd = dynamic_cast(m_device); + uint32_t currentSink = pd ? pd->sinkIndex() : PA_INVALID_INDEX; + + int idx = 0; + for ( TQPtrListIterator it(sinks); *it; ++it ) { + m_moveTargets.append( *it ); + int itemId = menu.insertItem( (*it)->name(), 1000 + idx ); + PulseDevice *sink = dynamic_cast(*it); + if ( sink && sink->paIndex() == currentSink ) + menu.setItemChecked( itemId, true ); + idx++; + } + connect( &menu, TQ_SIGNAL(activated(int)), + this, TQ_SLOT(onMoveToSink(int)) ); + } + } + } + + menu.exec( e->globalPos() ); + e->accept(); +} + +void DeviceWidget::onToggleMute() +{ + m_device->setMuted( !m_device->muted() ); +} + +void DeviceWidget::onDefaultClicked() +{ + if ( !m_model ) return; + if ( m_device->category() == AudioDevice::Output ) + m_model->setDefaultOutput( m_device ); + else if ( m_device->category() == AudioDevice::Input ) + m_model->setDefaultInput( m_device ); +} + +void DeviceWidget::onDefaultChanged( AudioDevice *dev ) +{ + if ( !m_defaultBtn ) return; + m_defaultBtn->blockSignals( true ); + m_defaultBtn->setChecked( dev == m_device ); + m_defaultBtn->blockSignals( false ); +} + +void DeviceWidget::onRecordingActive( bool active ) +{ + if ( m_recordingLed ) + m_recordingLed->setState( active ? KLed::On : KLed::Off ); +} + +void DeviceWidget::onSetDefault() +{ + onDefaultClicked(); +} + +void DeviceWidget::setSeparatorVisible( bool v ) +{ + if ( m_sep ) v ? m_sep->show() : m_sep->hide(); +} + +void DeviceWidget::onMoveToSink( int id ) +{ + if ( !m_model ) return; + int idx = id - 1000; + if ( idx < 0 || (uint)idx >= m_moveTargets.count() ) return; + m_model->moveSinkInputToSink( m_device, m_moveTargets.at(idx) ); } #include "devicewidget.moc" diff --git a/src/ui/devicewidget.h b/src/ui/devicewidget.h index 4a6cead..a5532bf 100644 --- a/src/ui/devicewidget.h +++ b/src/ui/devicewidget.h @@ -2,33 +2,66 @@ #include #include +#include +#include class AudioDevice; +class PulseModel; +class KLed; +class KLedButton; +class LevelMeter; class TQSlider; -class TQToolButton; +class BalanceKnob; class TQLabel; -class TQVBoxLayout; +class TQRadioButton; +class TQContextMenuEvent; +class TQFrame; class DeviceWidget : public TQWidget { TQ_OBJECT public: - DeviceWidget( AudioDevice *device, TQWidget *parent = 0 ); + DeviceWidget( AudioDevice *device, PulseModel *model, TQWidget *parent = 0 ); ~DeviceWidget() {} AudioDevice *device() const { return m_device; } + LevelMeter *levelMeter() const { return m_levelMeter; } + void setSeparatorVisible( bool v ); + +protected: + void contextMenuEvent( TQContextMenuEvent *e ); private slots: - void onVolumeChanged( int v ); // slider → device - void onMuteToggled(); // button → device - void onDeviceVolume( int v ); // device → slider - void onDeviceMute( bool m ); // device → button + void onVolumeChanged( int v ); + void onBalanceChanged( int v ); + void onMuteClicked(); + void onToggleMute(); + void onSetDefault(); + void onDefaultClicked(); + void onMoveToSink( int id ); + void onDefaultChanged( AudioDevice *dev ); + void onRecordingActive( bool active ); + + void onDeviceVolume( int v ); + void onDeviceMute( bool m ); + void onDevicePan( int p ); + void onDeviceLevel( float level ); void onDeviceName( const TQString &name ); private: - AudioDevice *m_device; - TQSlider *m_slider; - TQToolButton *m_muteButton; - TQLabel *m_label; + AudioDevice *m_device; + PulseModel *m_model; + TQFrame *m_sep; + TQSlider *m_volSlider; + BalanceKnob *m_balanceDial; + KLedButton *m_muteLed; + KLed *m_recordingLed; // null unless Input + TQRadioButton *m_defaultBtn; // null for Playback/Recording + TQLabel *m_volLabel; + LevelMeter *m_levelMeter; + TQWidget *m_label; + TQLabel *m_iconLabel; + + TQPtrList m_moveTargets; // populated by context menu build }; diff --git a/src/ui/kledbutton.cpp b/src/ui/kledbutton.cpp new file mode 100644 index 0000000..f3b5ecc --- /dev/null +++ b/src/ui/kledbutton.cpp @@ -0,0 +1,29 @@ +#include "kledbutton.h" +#include + +KLedButton::KLedButton( const TQColor &col, TQWidget *parent, const char *name ) + : KLed( col, parent, name ) +{ +} + +KLedButton::KLedButton( const TQColor &col, KLed::State st, KLed::Look look, + KLed::Shape shape, TQWidget *parent, const char *name ) + : KLed( col, st, look, shape, parent, name ) +{ +} + +void KLedButton::mousePressEvent( TQMouseEvent *e ) +{ + if ( e->button() == TQt::LeftButton ) { + toggle(); + emit stateChanged( state() == KLed::On ); + } +} + +TQSize KLedButton::sizeHint() const { return TQSize( 22, 22 ); } +TQSizePolicy KLedButton::sizePolicy() const +{ + return TQSizePolicy( TQSizePolicy::Fixed, TQSizePolicy::Fixed ); +} + +#include "kledbutton.moc" diff --git a/src/ui/kledbutton.h b/src/ui/kledbutton.h new file mode 100644 index 0000000..3bdadaa --- /dev/null +++ b/src/ui/kledbutton.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +class KLedButton : public KLed +{ + TQ_OBJECT + +public: + KLedButton( const TQColor &col = TQt::green, TQWidget *parent = 0, const char *name = 0 ); + KLedButton( const TQColor &col, KLed::State st, KLed::Look look, KLed::Shape shape, + TQWidget *parent = 0, const char *name = 0 ); + ~KLedButton() {} + + TQSize sizeHint() const; + TQSizePolicy sizePolicy() const; + +signals: + void stateChanged( bool newState ); + +protected: + void mousePressEvent( TQMouseEvent *e ); +}; diff --git a/src/ui/levelmeter.cpp b/src/ui/levelmeter.cpp new file mode 100644 index 0000000..52412de --- /dev/null +++ b/src/ui/levelmeter.cpp @@ -0,0 +1,67 @@ +#include "levelmeter.h" +#include +#include + +LevelMeter::LevelMeter( TQWidget *parent ) + : TQWidget(parent), m_level(0.0f) +{ + setBackgroundMode( NoBackground ); +} + +// Map linear 0..1 amplitude to dB display position 0..1 over a 60dB range. +// -60dB = bottom, 0dB = top. Matches how real VU meters look. +static float toDisplayLevel( float linear ) +{ + if ( linear < 0.001f ) return 0.0f; + float db = 20.0f * log10f( linear ); // e.g. 0.3 → -10.5 dB + float pos = ( db + 60.0f ) / 60.0f; // -60..0 dB → 0..1 + if ( pos < 0.0f ) pos = 0.0f; + if ( pos > 1.0f ) pos = 1.0f; + return pos; +} + +void LevelMeter::setLevel( float level ) +{ + if ( level < 0.0f ) level = 0.0f; + if ( level > 1.0f ) level = 1.0f; + + float display = toDisplayLevel( level ); + if ( display > m_level ) + m_level = m_level * 0.3f + display * 0.7f; // fast attack, not instant + else { + m_level *= 0.94f; // slow decay ~1.5s to floor + if ( m_level < 0.001f ) m_level = 0.0f; + } + update(); +} + +TQSize LevelMeter::sizeHint() const +{ + return TQSize( 8, 48 ); +} + +void LevelMeter::paintEvent( TQPaintEvent * ) +{ + TQPainter p( this ); + int w = width(); + int h = height(); + + p.fillRect( 0, 0, w, h, TQColor( 40, 40, 40 ) ); + + if ( m_level <= 0.0f ) return; + + struct Zone { float lo, hi; TQColor col; }; + static const Zone zones[3] = { + { 0.00f, 0.70f, TQColor( 50, 200, 50 ) }, + { 0.70f, 0.90f, TQColor( 230, 200, 0 ) }, + { 0.90f, 1.00f, TQColor( 220, 40, 40 ) }, + }; + + for ( int z = 0; z < 3; z++ ) { + if ( m_level <= zones[z].lo ) break; + float segEnd = ( m_level < zones[z].hi ) ? m_level : zones[z].hi; + int yTop = h - (int)( segEnd * h ); + int yBot = h - (int)( zones[z].lo * h ); + p.fillRect( 0, yTop, w, yBot - yTop, zones[z].col ); + } +} diff --git a/src/ui/levelmeter.h b/src/ui/levelmeter.h new file mode 100644 index 0000000..012c6f6 --- /dev/null +++ b/src/ui/levelmeter.h @@ -0,0 +1,17 @@ +#pragma once +#include + +class LevelMeter : public TQWidget +{ +public: + explicit LevelMeter( TQWidget *parent = 0 ); + + void setLevel( float level ); // 0.0 – 1.0, fast attack / slow decay + TQSize sizeHint() const; + +protected: + void paintEvent( TQPaintEvent * ); + +private: + float m_level; +}; diff --git a/src/ui/mixerwindow.cpp b/src/ui/mixerwindow.cpp index 91cea65..caa6af5 100644 --- a/src/ui/mixerwindow.cpp +++ b/src/ui/mixerwindow.cpp @@ -1,44 +1,174 @@ #include "mixerwindow.h" +#include "preferencesdlg.h" #include "../model/pulsemodel.h" #include #include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "tmixtray.h" +#include "devicespage.h" +#include +#include +#include + +// ---- tab helper ------------------------------------------------------------- static MixerWindow::Tab makeTab( KTabWidget *tabs, const TQString &label ) { MixerWindow::Tab t; - TQScrollView *scroll = new TQScrollView( tabs ); - scroll->setResizePolicy( TQScrollView::AutoOneFit ); - scroll->setHScrollBarMode( TQScrollView::Auto ); - scroll->setVScrollBarMode( TQScrollView::AlwaysOff ); - scroll->setFrameStyle( TQFrame::NoFrame ); - - t.page = new TQWidget( scroll->viewport() ); - scroll->addChild( t.page ); + + TQScrollView *sv = new TQScrollView( tabs ); + sv->setFrameStyle( TQFrame::NoFrame ); + sv->setHScrollBarMode( TQScrollView::Auto ); + sv->setVScrollBarMode( TQScrollView::AlwaysOff ); + sv->setResizePolicy( TQScrollView::AutoOneFit ); + + t.page = new TQWidget( sv->viewport() ); + sv->addChild( t.page, 0, 0 ); + t.layout = new TQHBoxLayout( t.page, 6, 4 ); + t.layout->addStretch( 1 ); + t.scroll = sv; + t.count = 0; - tabs->addTab( scroll, label ); + tabs->addTab( sv, label ); return t; } MixerWindow::MixerWindow( PulseModel *model, TQWidget *parent ) - : TQWidget(parent), m_model(model) + : TDEMainWindow(parent), m_model(model), m_prefsDlg(0), + m_devicesBtn(0), m_devicesPage(0), m_devicesInTabs(false), m_quitting(false) { - setCaption( i18n("TMix") ); + setPlainCaption( i18n("Volume Control") ); + setIcon( TDEGlobal::iconLoader()->loadIcon( "kmix", TDEIcon::Desktop, 48 ) ); + + // ---- menu bar ----------------------------------------------------------- + TDEPopupMenu *fileMenu = new TDEPopupMenu( this ); + fileMenu->insertItem( SmallIcon("application-exit"), i18n("&Quit"), this, TQ_SLOT(quit()) ); + + TDEPopupMenu *settingsMenu = new TDEPopupMenu( this ); + settingsMenu->insertItem( SmallIcon("configure"), i18n("&Configure TMix..."), + this, TQ_SLOT(showPreferences()) ); + + TDEPopupMenu *helpMenu = new TDEPopupMenu( this ); + helpMenu->insertItem( SmallIcon("help"), i18n("&About TMix"), this, TQ_SLOT(showAbout()) ); + + menuBar()->insertItem( i18n("&File"), fileMenu ); + menuBar()->insertItem( i18n("&Settings"), settingsMenu ); + menuBar()->insertItem( i18n("&Help"), helpMenu ); + + // ---- system tray -------------------------------------------------------- + m_tray = new TmixTray( this ); + m_tray->setModel( model ); + connect( model, TQ_SIGNAL(defaultOutputChanged(AudioDevice*)), + this, TQ_SLOT(onDefaultOutputChanged(AudioDevice*)) ); + onDefaultOutputChanged( model->defaultOutput() ); + + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + if ( cfg->readBoolEntry("DockInTray", true) ) + m_tray->show(); + + // ---- central widget ----------------------------------------------------- + TQWidget *central = new TQWidget( this ); + setCentralWidget( central ); + setMinimumSize( 300, 200 ); + + TQVBoxLayout *top = new TQVBoxLayout( central, 0, 0 ); - TQVBoxLayout *top = new TQVBoxLayout( this, 4, 0 ); - m_tabs = new KTabWidget( this ); - top->addWidget( m_tabs ); + // Outer stack: page 0 = mixer content, page 1 = devices (no-tab mode only) + m_outerStack = new TQWidgetStack( central ); + top->addWidget( m_outerStack, 1 ); + + TQWidget *mixerPage = new TQWidget( m_outerStack ); + TQVBoxLayout *mixerLayout = new TQVBoxLayout( mixerPage, 0, 0 ); + m_outerStack->addWidget( mixerPage, 0 ); + m_outerStack->raiseWidget( 0 ); + + m_stack = new TQWidgetStack( mixerPage ); + mixerLayout->addWidget( m_stack ); + + // Page 0: tabbed view + m_tabs = new KTabWidget( m_stack ); + m_stack->addWidget( m_tabs, 0 ); m_output = makeTab( m_tabs, i18n("Output") ); m_input = makeTab( m_tabs, i18n("Input") ); m_playback = makeTab( m_tabs, i18n("Playback") ); m_recording = makeTab( m_tabs, i18n("Recording") ); + // Page 1: all-in-one flat view (scrollable) + { + TQScrollView *sv = new TQScrollView( m_stack ); + sv->setFrameStyle( TQFrame::NoFrame ); + sv->setHScrollBarMode( TQScrollView::Auto ); + sv->setVScrollBarMode( TQScrollView::AlwaysOff ); + sv->setResizePolicy( TQScrollView::AutoOneFit ); + m_allPage = sv; + + m_allStrip = new TQWidget( sv->viewport() ); + sv->addChild( m_allStrip, 0, 0 ); + + m_allLayout = new TQHBoxLayout( m_allStrip, 2, 4 ); + m_allLayout->addStretch( 1 ); + + m_allCount = 0; + m_stack->addWidget( sv, 1 ); + } + + // Floating toggle button — child of central widget so TQWidgetStack page + // switches don't hide it; positioned over m_outerStack's bottom-right corner. + m_devicesBtn = new TQToolButton( central ); + m_devicesBtn->setIconSet( SmallIconSet("configure") ); + m_devicesBtn->resize( 24, 24 ); + TQToolTip::add( m_devicesBtn, i18n("Device Configuration") ); + connect( m_devicesBtn, TQ_SIGNAL(clicked()), this, TQ_SLOT(toggleDevices()) ); + central->installEventFilter( this ); + + // DevicesPage — placement depends on tab/no-tab mode (set below) + m_devicesPage = new DevicesPage( model, this ); + + { + TDEConfig *c = TDEGlobal::config(); + c->setGroup("View"); + bool noTabs = c->readBoolEntry("NoTabs", false); + m_stack->raiseWidget( noTabs ? 1 : 0 ); + // Place DevicesPage in the right home for the initial mode + if ( noTabs ) { + m_devicesPage->reparent( m_outerStack, 0, TQPoint(), false ); + m_outerStack->addWidget( m_devicesPage, 1 ); + m_devicesInTabs = false; + } else { + m_devicesPage->reparent( m_tabs, 0, TQPoint(), false ); + m_tabs->addTab( m_devicesPage, i18n("Devices") ); + m_devicesInTabs = true; + } + } + + setAutoSaveSettings( "MainWindow" ); + + // Position button after layout is set; hide if in tab mode (has its own tab) + repositionDevicesBtn(); + m_devicesBtn->setShown( !m_devicesInTabs ); + connect( model, TQ_SIGNAL(deviceAdded(AudioDevice*)), this, TQ_SLOT(onDeviceAdded(AudioDevice*)) ); connect( model, TQ_SIGNAL(deviceRemoved(AudioDevice*)), this, TQ_SLOT(onDeviceRemoved(AudioDevice*)) ); + connect( tqApp, TQ_SIGNAL(aboutToQuit()), this, TQ_SLOT(onAppAboutToQuit()) ); // Populate with any devices already known at construction time. AudioDevice::Category cats[] = { @@ -52,6 +182,37 @@ MixerWindow::MixerWindow( PulseModel *model, TQWidget *parent ) } } +bool MixerWindow::queryClose() +{ + if ( m_quitting || tdeApp->sessionSaving() ) + return true; + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + if ( cfg->readBoolEntry("DockInTray", true) ) { + saveMainWindowSettings( TDEGlobal::config(), "MainWindow" ); + TDEConfig *c = TDEGlobal::config(); + c->setGroup("MainWindow"); + c->writeEntry("XPos", x()); + c->writeEntry("YPos", y()); + c->sync(); + hide(); + return false; + } + return true; +} + + +void MixerWindow::showAbout() +{ + TDEAboutData about( + "tmix", I18N_NOOP("TMix"), "0.1", + I18N_NOOP("Volume control for TDE / PulseAudio"), + TDEAboutData::License_GPL, + "(c) 2026", 0, 0, 0 ); + TDEAboutApplication dlg( &about, this ); + dlg.exec(); +} + MixerWindow::Tab &MixerWindow::tabForCategory( AudioDevice::Category cat ) { switch ( cat ) { @@ -65,21 +226,199 @@ MixerWindow::Tab &MixerWindow::tabForCategory( AudioDevice::Category cat ) void MixerWindow::onDeviceAdded( AudioDevice *dev ) { - Tab &t = tabForCategory( dev->category() ); - TQWidget *w = dev->createWidget( t.page ); - t.layout->addWidget( w ); - w->show(); - m_widgets.insert( dev, w ); + if ( m_stack->id( m_stack->visibleWidget() ) == 1 ) { + TQWidget *w = dev->createWidget( m_allStrip ); + m_allLayout->insertWidget( m_allCount, w ); + m_allCount++; + w->show(); + m_widgets.insert( dev, w ); + } else { + Tab &t = tabForCategory( dev->category() ); + TQWidget *w = dev->createWidget( t.page ); + t.layout->insertWidget( t.count, w ); + t.count++; + w->show(); + m_widgets.insert( dev, w ); + } +} + + +void MixerWindow::onDefaultOutputChanged( AudioDevice *dev ) +{ + m_tray->setDevice( dev ); } void MixerWindow::onDeviceRemoved( AudioDevice *dev ) { TQWidget *w = m_widgets[dev]; if ( !w ) return; - Tab &t = tabForCategory( dev->category() ); - t.layout->remove( w ); + if ( m_stack->id( m_stack->visibleWidget() ) == 1 ) { + m_allLayout->remove( w ); + m_allCount--; + } else { + Tab &t = tabForCategory( dev->category() ); + t.layout->remove( w ); + t.count--; + } m_widgets.remove( dev ); delete w; } + +void MixerWindow::quit() +{ + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + if ( cfg->readBoolEntry("ConfirmQuit", false) ) { + int r = KMessageBox::questionYesNo( + this, + i18n("Are you sure you want to quit TMix?"), + i18n("Quit TMix"), + KStdGuiItem::quit(), + KStdGuiItem::cancel() ); + if ( r != KMessageBox::Yes ) + return; + } + m_quitting = true; + tqApp->quit(); +} + +void MixerWindow::onAppAboutToQuit() +{ + m_quitting = true; +} + +void MixerWindow::showPreferences() +{ + if ( !m_prefsDlg ) { + m_prefsDlg = new PreferencesDlg( this ); + connect( m_prefsDlg, TQ_SIGNAL(settingsChanged()), this, TQ_SLOT(applySettings()) ); + } + m_prefsDlg->load(); + m_prefsDlg->show(); + m_prefsDlg->raise(); + m_prefsDlg->setActiveWindow(); +} + +void MixerWindow::rebuildView() +{ + // Tear down all existing device widgets + TQMap::iterator it; + for ( it = m_widgets.begin(); it != m_widgets.end(); ++it ) + delete it.data(); + m_widgets.clear(); + m_output.count = m_input.count = m_playback.count = m_recording.count = 0; + m_allCount = 0; + + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("View"); + bool noTabs = cfg->readBoolEntry("NoTabs", false); + m_stack->raiseWidget( noTabs ? 1 : 0 ); + + if ( noTabs && m_devicesInTabs ) { + // Move DevicesPage from m_tabs → m_outerStack + m_tabs->removePage( m_devicesPage ); + m_devicesPage->reparent( m_outerStack, 0, TQPoint(), false ); + m_outerStack->addWidget( m_devicesPage, 1 ); + m_outerStack->raiseWidget( 0 ); + updateDevicesButton(); + m_devicesInTabs = false; + m_devicesBtn->show(); + repositionDevicesBtn(); + } else if ( !noTabs && !m_devicesInTabs ) { + // Move DevicesPage from m_outerStack → m_tabs + m_outerStack->removeWidget( m_devicesPage ); + m_devicesPage->reparent( m_tabs, 0, TQPoint(), false ); + m_tabs->addTab( m_devicesPage, i18n("Devices") ); + m_outerStack->raiseWidget( 0 ); + m_devicesInTabs = true; + m_devicesBtn->hide(); + } + + // Repopulate into whichever view is now active + AudioDevice::Category cats[] = { + AudioDevice::Output, AudioDevice::Input, + AudioDevice::Playback, AudioDevice::Recording + }; + for ( int i = 0; i < 4; i++ ) { + TQPtrList devs = m_model->devices( cats[i] ); + for ( TQPtrListIterator jt(devs); *jt; ++jt ) + onDeviceAdded( *jt ); + } +} + + +void MixerWindow::applySettings() +{ + TDEConfig *cfg = TDEGlobal::config(); + + cfg->setGroup("General"); + if ( cfg->readBoolEntry("DockInTray", true) ) + m_tray->show(); + else + m_tray->hide(); + + cfg->setGroup("View"); + bool noTabs = cfg->readBoolEntry("NoTabs", false); + if ( noTabs != ( m_stack->id( m_stack->visibleWidget() ) == 1 ) ) { + rebuildView(); + } else { + // Tabs mode: apply per-tab visibility + m_tabs->setTabEnabled( m_output.scroll, cfg->readBoolEntry("ShowOutput", true) ); + m_tabs->setTabEnabled( m_input.scroll, cfg->readBoolEntry("ShowInput", true) ); + m_tabs->setTabEnabled( m_playback.scroll, cfg->readBoolEntry("ShowPlayback", true) ); + m_tabs->setTabEnabled( m_recording.scroll, cfg->readBoolEntry("ShowRecording", true) ); + } +} + +void MixerWindow::toggleDevices() +{ + int next = ( m_outerStack->id( m_outerStack->visibleWidget() ) == 0 ) ? 1 : 0; + m_outerStack->raiseWidget( next ); + updateDevicesButton(); + repositionDevicesBtn(); + m_devicesBtn->raise(); +} + +void MixerWindow::updateDevicesButton() +{ + if ( m_outerStack->id( m_outerStack->visibleWidget() ) == 0 ) { + m_devicesBtn->setIconSet( SmallIconSet("configure") ); + TQToolTip::add( m_devicesBtn, i18n("Device Configuration") ); + } else { + m_devicesBtn->setIconSet( SmallIconSet("kmix") ); + TQToolTip::add( m_devicesBtn, i18n("Mixer") ); + } +} + +void MixerWindow::repositionDevicesBtn() +{ + int bw = m_devicesBtn->width() ? m_devicesBtn->width() : 24; + int bh = m_devicesBtn->height() ? m_devicesBtn->height() : 24; + // Position in bottom-right of m_outerStack, mapped to centralWidget coords + TQPoint origin = m_outerStack->mapTo( centralWidget(), TQPoint(0, 0) ); + int x = origin.x() + m_outerStack->width() - bw - 4; + int y = origin.y() + m_outerStack->height() - bh - 4; + m_devicesBtn->move( x, y ); + m_devicesBtn->raise(); +} + +bool MixerWindow::eventFilter( TQObject *obj, TQEvent *e ) +{ + if ( obj == centralWidget() && e->type() == TQEvent::Resize ) + repositionDevicesBtn(); + return false; +} + +void MixerWindow::showEvent( TQShowEvent *e ) +{ + TDEMainWindow::showEvent( e ); + repositionDevicesBtn(); + + TDEConfig *c = TDEGlobal::config(); + c->setGroup("MainWindow"); + if ( c->hasKey("XPos") && c->hasKey("YPos") ) + move( c->readNumEntry("XPos"), c->readNumEntry("YPos") ); +} + #include "mixerwindow.moc" diff --git a/src/ui/mixerwindow.h b/src/ui/mixerwindow.h index f6b7be8..c472246 100644 --- a/src/ui/mixerwindow.h +++ b/src/ui/mixerwindow.h @@ -1,15 +1,19 @@ #pragma once -#include #include +#include #include "../model/audiodevice.h" class PulseModel; class KTabWidget; class TQHBoxLayout; +class TQWidgetStack; +class TQToolButton; +class DevicesPage; +class TQResizeEvent; -class MixerWindow : public TQWidget +class MixerWindow : public TDEMainWindow { TQ_OBJECT @@ -17,22 +21,49 @@ public: explicit MixerWindow( PulseModel *model, TQWidget *parent = 0 ); ~MixerWindow() {} -private slots: - void onDeviceAdded( AudioDevice *dev ); - void onDeviceRemoved( AudioDevice *dev ); - -public: struct Tab { + TQWidget *scroll; TQWidget *page; TQHBoxLayout *layout; + int count; }; -private: +protected: + bool queryClose(); + bool eventFilter( TQObject *obj, TQEvent *e ); + void showEvent( TQShowEvent *e ); +private slots: + void onDeviceAdded( AudioDevice *dev ); + void onDeviceRemoved( AudioDevice *dev ); + void onDefaultOutputChanged( AudioDevice *dev ); + void showAbout(); + void showPreferences(); + void applySettings(); + void quit(); + void onAppAboutToQuit(); + void toggleDevices(); + +private: Tab &tabForCategory( AudioDevice::Category cat ); + void rebuildView(); + void updateDevicesButton(); + void repositionDevicesBtn(); - PulseModel *m_model; - KTabWidget *m_tabs; + PulseModel *m_model; + KTabWidget *m_tabs; + class PreferencesDlg *m_prefsDlg; + TQWidgetStack *m_outerStack; + TQWidgetStack *m_stack; + TQWidget *m_allPage; + TQWidget *m_allStrip; + TQHBoxLayout *m_allLayout; + int m_allCount; + class TmixTray *m_tray; + TQToolButton *m_devicesBtn; + DevicesPage *m_devicesPage; + bool m_devicesInTabs; // true = DevicesPage is a tab in m_tabs + bool m_quitting; Tab m_output; Tab m_input; diff --git a/src/ui/preferencesdlg.cpp b/src/ui/preferencesdlg.cpp new file mode 100644 index 0000000..b6df120 --- /dev/null +++ b/src/ui/preferencesdlg.cpp @@ -0,0 +1,111 @@ +#include "preferencesdlg.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +PreferencesDlg::PreferencesDlg( TQWidget *parent ) + : KDialogBase( Tabbed, i18n("Configure TMix"), + Ok | Apply | Cancel, Ok, parent ) +{ + // ---- General ------------------------------------------------------------- + TQFrame *gen = addPage( i18n("General") ); + TQVBoxLayout *gl = new TQVBoxLayout( gen, marginHint(), spacingHint() ); + + m_dockInTray = new TQCheckBox( i18n("Dock in system tray"), gen ); + gl->addWidget( m_dockInTray ); + + m_showPopup = new TQCheckBox( i18n("Show mini volume popup on tray click"), gen ); + gl->addWidget( m_showPopup ); + + m_showRecTray = new TQCheckBox( i18n("Show microphone-in-use icon in tray"), gen ); + gl->addWidget( m_showRecTray ); + + m_confirmQuit = new TQCheckBox( i18n("Ask for confirmation before quitting"), gen ); + gl->addWidget( m_confirmQuit ); + + + gl->addSpacing( 8 ); + + TQHBoxLayout *stepRow = new TQHBoxLayout( 0, 0, spacingHint() ); + stepRow->addWidget( new TQLabel( i18n("Scroll wheel volume step:"), gen ) ); + m_scrollStep = new TQSpinBox( 1, 20, 1, gen ); + m_scrollStep->setSuffix( i18n(" %") ); + stepRow->addWidget( m_scrollStep ); + stepRow->addStretch(); + gl->addLayout( stepRow ); + + gl->addStretch(); + + // ---- View ---------------------------------------------------------------- + TQFrame *view = addPage( i18n("View") ); + TQVBoxLayout *vl = new TQVBoxLayout( view, marginHint(), spacingHint() ); + + m_noTabs = new TQCheckBox( i18n("Show all devices in one view (no tabs)"), view ); + vl->addWidget( m_noTabs ); + + vl->addSpacing( 8 ); + vl->addWidget( new TQLabel( i18n("Show tabs:"), view ) ); + m_showOutput = new TQCheckBox( i18n("Output"), view ); + m_showInput = new TQCheckBox( i18n("Input"), view ); + m_showPlayback = new TQCheckBox( i18n("Playback"), view ); + m_showRecording = new TQCheckBox( i18n("Recording"), view ); + vl->addWidget( m_showOutput ); + vl->addWidget( m_showInput ); + vl->addWidget( m_showPlayback ); + vl->addWidget( m_showRecording ); + vl->addStretch(); + + load(); +} + +void PreferencesDlg::load() +{ + TDEConfig *cfg = TDEGlobal::config(); + + cfg->setGroup("General"); + m_dockInTray->setChecked( cfg->readBoolEntry("DockInTray", true) ); + m_showPopup->setChecked( cfg->readBoolEntry("ShowPopup", true) ); + m_showRecTray->setChecked( cfg->readBoolEntry("ShowRecordingTray", true) ); + m_confirmQuit->setChecked( cfg->readBoolEntry("ConfirmQuit", false) ); + m_scrollStep->setValue( cfg->readNumEntry( "ScrollStep", 5) ); + + cfg->setGroup("View"); + m_noTabs->setChecked( cfg->readBoolEntry("NoTabs", false) ); + m_showOutput->setChecked( cfg->readBoolEntry("ShowOutput", true) ); + m_showInput->setChecked( cfg->readBoolEntry("ShowInput", true) ); + m_showPlayback->setChecked( cfg->readBoolEntry("ShowPlayback", true) ); + m_showRecording->setChecked( cfg->readBoolEntry("ShowRecording", true) ); +} + +void PreferencesDlg::save() +{ + TDEConfig *cfg = TDEGlobal::config(); + + cfg->setGroup("General"); + cfg->writeEntry( "DockInTray", m_dockInTray->isChecked() ); + cfg->writeEntry( "ShowPopup", m_showPopup->isChecked() ); + cfg->writeEntry( "ShowRecordingTray", m_showRecTray->isChecked() ); + cfg->writeEntry( "ConfirmQuit", m_confirmQuit->isChecked() ); + cfg->writeEntry( "ScrollStep", m_scrollStep->value() ); + + cfg->setGroup("View"); + cfg->writeEntry( "NoTabs", m_noTabs->isChecked() ); + cfg->writeEntry( "ShowOutput", m_showOutput->isChecked() ); + cfg->writeEntry( "ShowInput", m_showInput->isChecked() ); + cfg->writeEntry( "ShowPlayback", m_showPlayback->isChecked() ); + cfg->writeEntry( "ShowRecording", m_showRecording->isChecked() ); + + cfg->sync(); + emit settingsChanged(); +} + +void PreferencesDlg::slotOk() { save(); accept(); } +void PreferencesDlg::slotApply() { save(); } + +#include "preferencesdlg.moc" diff --git a/src/ui/preferencesdlg.h b/src/ui/preferencesdlg.h new file mode 100644 index 0000000..94f5682 --- /dev/null +++ b/src/ui/preferencesdlg.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +class TQCheckBox; +class TQSpinBox; + +class PreferencesDlg : public KDialogBase +{ + TQ_OBJECT + +public: + PreferencesDlg( TQWidget *parent = 0 ); + void load(); + +signals: + void settingsChanged(); + +protected slots: + void slotOk(); + void slotApply(); + +private: + void save(); + + TQCheckBox *m_dockInTray; + TQCheckBox *m_showPopup; + TQCheckBox *m_showRecTray; + TQCheckBox *m_confirmQuit; + TQSpinBox *m_scrollStep; + + TQCheckBox *m_noTabs; + TQCheckBox *m_showOutput; + TQCheckBox *m_showInput; + TQCheckBox *m_showPlayback; + TQCheckBox *m_showRecording; +}; diff --git a/src/ui/tmixapp.cpp b/src/ui/tmixapp.cpp new file mode 100644 index 0000000..628e475 --- /dev/null +++ b/src/ui/tmixapp.cpp @@ -0,0 +1,20 @@ +#include "tmixapp.h" +#include "mixerwindow.h" + +TmixApp::TmixApp() : KUniqueApplication(), m_win(0), m_firstInstance(true) {} + +int TmixApp::newInstance() +{ + if ( m_firstInstance ) { + m_firstInstance = false; + return 0; + } + if ( m_win ) { + m_win->show(); + m_win->raise(); + m_win->setActiveWindow(); + } + return 0; +} + +#include "tmixapp.moc" diff --git a/src/ui/tmixapp.h b/src/ui/tmixapp.h new file mode 100644 index 0000000..8f41245 --- /dev/null +++ b/src/ui/tmixapp.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +class MixerWindow; + +class TmixApp : public KUniqueApplication +{ + TQ_OBJECT +public: + TmixApp(); + int newInstance(); + void setMainWindow( MixerWindow *w ) { m_win = w; } +private: + MixerWindow *m_win; + bool m_firstInstance; +}; diff --git a/src/ui/tmixpopup.cpp b/src/ui/tmixpopup.cpp new file mode 100644 index 0000000..068c66e --- /dev/null +++ b/src/ui/tmixpopup.cpp @@ -0,0 +1,134 @@ +#include "tmixpopup.h" +#include "devicewidget.h" +#include "../model/audiodevice.h" +#include "../model/pulsemodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TmixPopup::TmixPopup( PulseModel *model, TQWidget *parent ) + : TQWidget( parent, "TmixPopup", + WStyle_Customize | WType_Popup | WStyle_DialogBorder ), + m_model(model), m_devWidget(0) +{ + // Single frame fills the popup — header and content share the same border + TQVBoxLayout *outer = new TQVBoxLayout( this, 0, 0 ); + + TQFrame *frame = new TQFrame( this ); + frame->setFrameStyle( TQFrame::NoFrame ); + outer->addWidget( frame ); + + m_layout = new TQVBoxLayout( frame, 0, 0 ); + + // Thin title strip — inside the frame, so edges line up exactly + TQLabel *header = new TQLabel( i18n("Volume"), frame ); + header->setAlignment( TQt::AlignCenter ); + header->setFixedHeight( 23 ); + header->setPaletteBackgroundColor( palette().active().mid() ); + header->setPaletteForegroundColor( palette().active().text() ); + m_layout->addWidget( header ); + + // Spacer between header and device widget + m_layout->addSpacing( 4 ); + + // device widget inserted at index 0 by setDevice() + + m_layout->addSpacing( 4 ); + + // "Mixer" button — flat, compact + TQPushButton *btn = new TQPushButton( i18n("Mixer"), frame ); + btn->setFlat( true ); + btn->setFixedWidth( 70 ); + m_layout->addWidget( btn, 0, TQt::AlignHCenter ); + m_layout->addSpacing( 4 ); + connect( btn, TQ_SIGNAL(clicked()), this, TQ_SIGNAL(showMixerRequested()) ); + + connect( model, TQ_SIGNAL(defaultOutputChanged(AudioDevice*)), + this, TQ_SLOT(onDefaultOutputChanged(AudioDevice*)) ); + + setDevice( model->defaultOutput() ); +} + +void TmixPopup::setDevice( AudioDevice *dev ) +{ + if ( m_devWidget ) { + m_layout->remove( m_devWidget ); + delete m_devWidget; + m_devWidget = 0; + } + if ( !dev ) return; + + m_devWidget = new DeviceWidget( dev, m_model, + static_cast( m_layout->parent() ) ); + m_devWidget->setSeparatorVisible( false ); + m_layout->insertWidget( 2, m_devWidget ); + m_devWidget->show(); + adjustSize(); +} + +void TmixPopup::onDefaultOutputChanged( AudioDevice *dev ) +{ + setDevice( dev ); +} + +void TmixPopup::toggleAt( const TQPoint &trayPos, const TQSize &traySize ) +{ + if ( isVisible() ) { + hide(); + return; + } + + adjustSize(); + + TQRect screen = TQApplication::desktop()->screenGeometry( trayPos ); + + int x = trayPos.x() + traySize.width() / 2 - width() / 2; + int y = trayPos.y() - height(); + + if ( y < screen.top() ) + y = trayPos.y() + traySize.height(); + + if ( x + width() > screen.right() ) + x = screen.right() - width(); + if ( x < screen.left() ) + x = screen.left(); + + move( x, y ); + show(); + + KWin::setState( winId(), NET::KeepAbove | NET::SkipTaskbar | NET::SkipPager ); + KWin::setOnAllDesktops( winId(), true ); +} + +bool TmixPopup::justHidden() const +{ + return m_hideTime.isValid() && m_hideTime.elapsed() < 300; +} + +void TmixPopup::hideEvent( TQHideEvent *e ) +{ + TQWidget::hideEvent( e ); + m_hideTime.start(); +} + +void TmixPopup::wheelEvent( TQWheelEvent *e ) +{ + AudioDevice *dev = m_model->defaultOutput(); + if ( !dev ) return; + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + int step = cfg->readNumEntry("ScrollStep", 5); + int delta = e->delta() > 0 ? step : -step; + int vol = TQMAX( 0, TQMIN( 100, dev->volume() + delta ) ); + dev->setVolume( vol ); +} + +#include "tmixpopup.moc" diff --git a/src/ui/tmixpopup.h b/src/ui/tmixpopup.h new file mode 100644 index 0000000..4dbdc9a --- /dev/null +++ b/src/ui/tmixpopup.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +class PulseModel; +class AudioDevice; +class DeviceWidget; +class TQVBoxLayout; +class TQPushButton; + +class TmixPopup : public TQWidget +{ + TQ_OBJECT + +public: + explicit TmixPopup( PulseModel *model, TQWidget *parent = 0 ); + + // Position relative to tray icon and show, or hide if already visible. + void toggleAt( const TQPoint &trayGlobalPos, const TQSize &traySize ); + + // True for ~300ms after hiding — prevents re-show on the same click. + bool justHidden() const; + +signals: + void showMixerRequested(); + +protected: + void hideEvent( TQHideEvent *e ); + void wheelEvent( TQWheelEvent *e ); + +private slots: + void onDefaultOutputChanged( AudioDevice *dev ); + +private: + void setDevice( AudioDevice *dev ); + + PulseModel *m_model; + DeviceWidget *m_devWidget; + TQVBoxLayout *m_layout; + TQTime m_hideTime; +}; diff --git a/src/ui/tmixtray.cpp b/src/ui/tmixtray.cpp new file mode 100644 index 0000000..c0980f0 --- /dev/null +++ b/src/ui/tmixtray.cpp @@ -0,0 +1,182 @@ +#include "tmixtray.h" +#include "tmixpopup.h" +#include "../model/audiodevice.h" +#include "../model/pulsemodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TmixTray::TmixTray( TQWidget *parent ) + : KSystemTray(parent), m_model(0), m_device(0), m_recTray(0), + m_popup(0), m_recordingCount(0) +{ + setCaption( i18n("Volume Control") ); + + TQPixmap titleIcon = TDEGlobal::iconLoader()->loadIcon( + "kmix", TDEIcon::Small, 16, TDEIcon::DefaultState, 0, true ); + if ( titleIcon.isNull() ) + titleIcon = TDEGlobal::iconLoader()->loadIcon( + "audio-volume-medium", TDEIcon::Small, 16, TDEIcon::DefaultState, 0, true ); + contextMenu()->changeTitle( contextMenu()->idAt(0), titleIcon, i18n("Volume Control") ); + + updateIcon(); +} + +void TmixTray::setDevice( AudioDevice *dev ) +{ + if ( m_device ) + disconnect( m_device, 0, this, 0 ); + m_device = dev; + if ( m_device ) { + connect( m_device, TQ_SIGNAL(volumeChanged(int)), this, TQ_SLOT(updateIcon()) ); + connect( m_device, TQ_SIGNAL(muteChanged(bool)), this, TQ_SLOT(updateIcon()) ); + } + updateIcon(); +} + +void TmixTray::setModel( PulseModel *model ) +{ + m_model = model; + TQPtrList inputs = model->devices( AudioDevice::Input ); + for ( TQPtrListIterator it(inputs); *it; ++it ) { + connect( *it, TQ_SIGNAL(recordingActiveChanged(bool)), + this, TQ_SLOT(onRecordingActive(bool)) ); + } + connect( model, TQ_SIGNAL(deviceAdded(AudioDevice*)), + this, TQ_SLOT(onDeviceAdded(AudioDevice*)) ); +} + +void TmixTray::mousePressEvent( TQMouseEvent *e ) +{ + if ( e->button() == TQt::LeftButton ) { + if ( !m_model ) { KSystemTray::mousePressEvent(e); return; } + + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + bool showPopup = cfg->readBoolEntry("ShowPopup", true); + + if ( showPopup ) { + if ( !m_popup ) { + m_popup = new TmixPopup( m_model, this ); + connect( m_popup, TQ_SIGNAL(showMixerRequested()), + parentWidget(), TQ_SLOT(show()) ); + } + if ( m_popup->justHidden() ) { e->accept(); return; } + m_popup->toggleAt( mapToGlobal( TQPoint(0,0) ), size() ); + } else { + TQWidget *win = parentWidget(); + win->isVisible() ? win->hide() : win->show(); + } + e->accept(); + return; + } + KSystemTray::mousePressEvent( e ); +} + +void TmixTray::updateIcon() +{ + int vol = m_device ? m_device->volume() : 0; + bool mute = m_device ? m_device->muted() : true; + + const char *pic; + if ( mute || vol == 0 ) pic = "audio-volume-muted.png"; + else if ( vol < 34 ) pic = "audio-volume-low.png"; + else if ( vol < 67 ) pic = "audio-volume-medium.png"; + else pic = "audio-volume-high.png"; + + TQString path = TDEGlobal::dirs()->findResource( "data", + TQString("tmix/pics/crystal/") + pic ); + if ( !path.isEmpty() ) + setPixmap( TQPixmap(path) ); +} + +void TmixTray::onRecordingActive( bool active ) +{ + m_recordingCount += active ? 1 : -1; + if ( m_recordingCount < 0 ) m_recordingCount = 0; + updateRecordingTray(); +} + +void TmixTray::onDeviceAdded( AudioDevice *dev ) +{ + if ( dev->category() == AudioDevice::Input ) + connect( dev, TQ_SIGNAL(recordingActiveChanged(bool)), + this, TQ_SLOT(onRecordingActive(bool)) ); +} + +void TmixTray::updateRecordingTray() +{ + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + bool showRec = cfg->readBoolEntry("ShowRecordingTray", true); + + if ( m_recordingCount > 0 && showRec ) { + if ( !m_recTray ) { + m_recTray = new KSystemTray( parentWidget() ); + m_recTray->setCaption( i18n("Microphone Active") ); + + TQString path = TDEGlobal::dirs()->findResource( "data", + "tmix/pics/mix_microphone_recording.png" ); + if ( path.isEmpty() ) + path = TDEGlobal::dirs()->findResource( "data", + "tmix/pics/mix_microphone.png" ); + + if ( !path.isEmpty() ) + m_recTray->setPixmap( TQPixmap( path ) ); + } + + // Build tooltip: list names of active recording streams + if ( m_model ) { + TQStringList names; + TQPtrList streams = m_model->devices( AudioDevice::Recording ); + for ( TQPtrListIterator it(streams); *it; ++it ) + names.append( (*it)->name() ); + TQString tip = names.isEmpty() + ? i18n("Microphone in use") + : i18n("Recording: ") + names.join(", "); + TQToolTip::add( m_recTray, tip ); + } + + m_recTray->show(); + } else if ( m_recTray ) { + m_recTray->hide(); + } +} + +void TmixTray::wheelEvent( TQWheelEvent *e ) +{ + if ( !m_device ) return; + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + int step = cfg->readNumEntry("ScrollStep", 5); + int delta = e->delta() > 0 ? step : -step; + int newVol = m_device->volume() + delta; + if ( newVol < 0 ) newVol = 0; + if ( newVol > 100 ) newVol = 100; + m_device->setVolume( newVol ); +} + +void TmixTray::contextMenuAboutToShow( TDEPopupMenu *menu ) +{ + while ( menu->count() > 1 ) + menu->removeItemAt( 1 ); + + TQWidget *win = parentWidget(); + menu->insertItem( SmallIcon("configure"), i18n("&Configure TMix..."), + win, TQ_SLOT(showPreferences()) ); + menu->insertItem( SmallIcon("help"), i18n("&About TMix"), + win, TQ_SLOT(showAbout()) ); + menu->insertSeparator(); + menu->insertItem( SmallIcon("application-exit"), i18n("&Quit"), + win, TQ_SLOT(quit()) ); +} + +#include "tmixtray.moc" diff --git a/src/ui/tmixtray.h b/src/ui/tmixtray.h new file mode 100644 index 0000000..08a5fb5 --- /dev/null +++ b/src/ui/tmixtray.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +class AudioDevice; +class PulseModel; +class TmixPopup; + +class TmixTray : public KSystemTray +{ + TQ_OBJECT + +public: + TmixTray( TQWidget *parent ); + void setDevice( AudioDevice *dev ); + void setModel( PulseModel *model ); + +public slots: + void updateIcon(); + void onRecordingActive( bool active ); + void onDeviceAdded( AudioDevice *dev ); + +protected: + void mousePressEvent( TQMouseEvent *e ); + void wheelEvent( TQWheelEvent *e ); + void contextMenuAboutToShow( TDEPopupMenu *menu ); + +private: + void updateRecordingTray(); + + PulseModel *m_model; + AudioDevice *m_device; + KSystemTray *m_recTray; + TmixPopup *m_popup; + int m_recordingCount; +}; -- cgit v1.2.3