diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-05-15 10:10:04 -0400 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-05-15 10:10:04 -0400 |
| commit | e776bc768cf9afca1867200e25d64d315cd72a3e (patch) | |
| tree | 6745527b939c9d37147d7dc98e8664437ee433f6 | |
| parent | 4e602e78cdfc210ab7781668df2a88afb923258b (diff) | |
Full mixer implementation — tray, popup, prefs, devices tab with port indicators
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 <noreply@anthropic.com>
| -rw-r--r-- | CMakeLists.txt | 17 | ||||
| -rw-r--r-- | TODO | 221 | ||||
| -rw-r--r-- | src/main.cpp | 28 | ||||
| -rw-r--r-- | src/model/audiodevice.h | 7 | ||||
| -rw-r--r-- | src/model/pulsedevice.cpp | 227 | ||||
| -rw-r--r-- | src/model/pulsedevice.h | 42 | ||||
| -rw-r--r-- | src/model/pulsemodel.cpp | 451 | ||||
| -rw-r--r-- | src/model/pulsemodel.h | 66 | ||||
| -rw-r--r-- | src/ui/balanceknob.cpp | 124 | ||||
| -rw-r--r-- | src/ui/balanceknob.h | 34 | ||||
| -rw-r--r-- | src/ui/devicespage.cpp | 251 | ||||
| -rw-r--r-- | src/ui/devicespage.h | 42 | ||||
| -rw-r--r-- | src/ui/devicewidget.cpp | 384 | ||||
| -rw-r--r-- | src/ui/devicewidget.h | 55 | ||||
| -rw-r--r-- | src/ui/kledbutton.cpp | 29 | ||||
| -rw-r--r-- | src/ui/kledbutton.h | 24 | ||||
| -rw-r--r-- | src/ui/levelmeter.cpp | 67 | ||||
| -rw-r--r-- | src/ui/levelmeter.h | 17 | ||||
| -rw-r--r-- | src/ui/mixerwindow.cpp | 381 | ||||
| -rw-r--r-- | src/ui/mixerwindow.h | 51 | ||||
| -rw-r--r-- | src/ui/preferencesdlg.cpp | 111 | ||||
| -rw-r--r-- | src/ui/preferencesdlg.h | 37 | ||||
| -rw-r--r-- | src/ui/tmixapp.cpp | 20 | ||||
| -rw-r--r-- | src/ui/tmixapp.h | 17 | ||||
| -rw-r--r-- | src/ui/tmixpopup.cpp | 134 | ||||
| -rw-r--r-- | src/ui/tmixpopup.h | 42 | ||||
| -rw-r--r-- | src/ui/tmixtray.cpp | 182 | ||||
| -rw-r--r-- | src/ui/tmixtray.h | 36 |
28 files changed, 2950 insertions, 147 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 29462ff..de5ba5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,8 @@ cmake_minimum_required( VERSION ${TDE_CMAKE_MINIMUM_VERSION} ) project( tmix ) include( TDEMacros ) +include( TDESetupPaths ) +tde_setup_paths() include( FindPkgConfig ) pkg_check_modules( PULSE REQUIRED libpulse ) @@ -22,18 +24,33 @@ link_directories( /opt/trinity/lib ) +install( + DIRECTORY img/ + DESTINATION ${DATA_INSTALL_DIR}/tmix/pics + FILES_MATCHING PATTERN "*.png" +) + tde_add_executable( tmix AUTOMOC SOURCES src/main.cpp src/model/audiodevice.cpp src/model/pulsedevice.cpp src/model/pulsemodel.cpp + src/ui/balanceknob.cpp src/ui/devicewidget.cpp + src/ui/preferencesdlg.cpp + src/ui/kledbutton.cpp + src/ui/levelmeter.cpp + src/ui/devicespage.cpp + src/ui/tmixpopup.cpp + src/ui/tmixtray.cpp + src/ui/tmixapp.cpp src/ui/mixerwindow.cpp LINK tqt-mt tdecore tdeui + DCOP ${PULSE_LIBRARIES} DESTINATION ${BIN_INSTALL_DIR} ) @@ -0,0 +1,221 @@ +tmix — todo list +================ + +## UAT checklist +Run through this before any release / major refactor. + +### Startup +- [ ] App launches without crash when PA is running +- [ ] App launches without crash when PA is NOT running (graceful fail) +- [ ] Window title shows "TMix - Volume Control" (not "TMix - Volume Control - TMix") +- [ ] Tray icon appears when DockInTray=true +- [ ] Window hidden on launch when DockInTray=true +- [ ] Window visible on launch when DockInTray=false + +### Tabs / device population +- [ ] Output tab shows all PA sinks (hardware outputs) +- [ ] Input tab shows all PA sources (mics etc.) but NOT monitor sources +- [ ] Playback tab shows per-app sink inputs; tmix-peak streams NOT shown +- [ ] Recording tab shows per-app source outputs; tmix-peak streams NOT shown +- [ ] Devices added/removed live without restart (plug/unplug USB audio, start/stop app) +- [ ] Correct icon shown per device (headphone, microphone, digital, fallback) +- [ ] Correct app icon shown for Playback streams (Amarok, Firefox, etc.) +- [ ] Vertical label shows device/stream name + +### Volume +- [ ] Dragging volume slider changes PA volume in real time +- [ ] Volume % label updates live while dragging +- [ ] Scrolling on tray icon changes default output volume +- [ ] Volume change from another app (pavucontrol) reflects in tmix slider + label +- [ ] Volume clamped 0–100%, no overflow + +### Mute +- [ ] Green LED = unmuted, off = muted +- [ ] Clicking LED toggles mute state in PA +- [ ] Mute change from another app reflects in LED +- [ ] Right-click "Mute" mutes; item shows "Muted" with checkmark when muted; "Unmute" when muted + +### Balance knob (Output + Playback only) +- [ ] Knob renders cleanly (antialiased, no flicker) +- [ ] Dragging up increases right balance, dragging down increases left +- [ ] Scroll wheel adjusts ±5 +- [ ] Double-click resets to centre +- [ ] PA channel volumes update correctly (left attenuated when right-heavy) +- [ ] Balance change from pavucontrol reflects in knob +- [ ] Input tab has NO balance knob + +### Level meter +- [ ] Output: meter responds to audio playback through that sink +- [ ] Input: meter responds to microphone input +- [ ] Playback: meter shows per-stream level (only that app's audio, not the whole sink) +- [ ] Recording: no meter (acceptable; source output monitoring not implemented) +- [ ] Meter decays smoothly; attack is fast, decay is slower +- [ ] Meter shows 0 when muted or silent + +### Default device +- [ ] Radio button filled on the current PA default output +- [ ] Radio button filled on the current PA default input +- [ ] Clicking empty radio button sets that device as PA default +- [ ] Default change from pavucontrol updates radio buttons in tmix +- [ ] Only one radio button filled at a time per tab +- [ ] Tray icon reflects default output volume/mute state + +### Recording indicator +- [ ] Red LED on Input widget is OFF when no app is recording from that source +- [ ] Red LED turns ON when an app starts recording from that source +- [ ] Red LED turns OFF when recording app stops +- [ ] Second tray icon (microphone) appears when any input is active +- [ ] Second tray icon disappears when all recording stops + +### Right-click context menu +- [ ] Right-click on any device widget shows context menu +- [ ] Menu title shows device name +- [ ] "Mute"/"Muted" item works (see Mute section) +- [ ] Output: "Set as Default Output" present and functional +- [ ] Input: "Set as Default Input" present and functional +- [ ] Playback: "Move to Sink" section lists all available sinks +- [ ] Current sink shown with checkmark in "Move to Sink" list +- [ ] Selecting a different sink moves the stream +- [ ] Output/Input: no "Move to Sink" section +- [ ] Playback/Recording: no "Set as Default" item + +### System tray +- [ ] Volume icon shows muted/low/medium/high based on default output +- [ ] Icon updates when volume changes (slider, scroll wheel, external) +- [ ] Icon updates when mute state changes +- [ ] Scroll wheel on tray raises/lowers default output volume +- [ ] Left-click tray shows/hides main window +- [ ] Right-click tray shows context menu with Quit (only one Quit entry) + +### Window behaviour +- [ ] Closing window hides to tray (DockInTray=true), does not quit +- [ ] Closing window quits (DockInTray=false) +- [ ] Session logout closes app cleanly (sessionSaving check) +- [ ] File → Quit exits cleanly, no crash or double-free + +### Stress / edge cases +- [ ] Rapidly toggle mute — no crash, no stuck state +- [ ] Rapidly drag slider — no crash, PA not flooded beyond responsiveness +- [ ] Kill and restart PulseAudio while tmix is open — tmix recovers or fails gracefully +- [ ] Open tmix with 0 devices on each tab — no crash, empty tabs look OK +- [ ] Open tmix with 10+ playback streams — layout scrolls, no overlap + +## Bugs / Polish +- [x] Taskbar / dock icon missing +- [x] Recording tray icon red dot not appearing +- [x] Icons: app-specific icons for Firefox etc. +- [x] Input devices always show microphone icon regardless of PA device name hint +- [x] Right-click "Move to Sink": show checkmark on the sink the stream is currently on +- [x] Right-click mute item: fixed label "Mute" with checkmark when muted +- [x] Scroll area for 10+ devices — TQScrollView wrapping tab pages and flat strip; + scroll bars appear only when content exceeds window width +- [x] Window does not resize on card profile change (removed adjustSize()) +- [x] Window position persists across restarts +- [x] Single-instance enforcement — second launch raises/shows the existing window (KUniqueApplication) +- [x] Settings dialog is non-blocking (modeless show(), reloads config on each open) +- [ ] Devices tab — sex it up vs pavucontrol-qt: + - Port availability indicators (plugged/unplugged) with port-type icons + (headphones, mic, HDMI, speaker, S/PDIF) from pa_card_port_info.type + - availability_group handling: when a combo jack is detected (multiple ports + share an availability_group), show a "What did you plug in?" prompt so the + user can disambiguate headphones vs headset vs mic + - Hide unavailable profiles by default (toggle to show all) + +## Features + +### Implemented +- [x] Volume % label (live, updates on drag) +- [x] Pan / balance slider per stereo device (reads + writes PA per-channel cvolume) +- [x] Level meter — Output/Input via sink/source monitor; Playback via pa_stream_set_monitor_stream (per-stream) +- [x] Slider tick marks every 5% +- [x] Separator between devices +- [x] Playback slider fix (was broken due to hardcoded 2-channel cvolume) +- [x] Mute button: 3D toggle style +- [x] App icons with multimedia fallback; amarok/TDE apps resolve via binary name +- [x] tmix-peak streams hidden from Recording tab and pavucontrol (media.role=abstract) +- [x] Menu bar — File: Quit, Settings: Configure TMix, Help: About TMix +- [x] Per-device right-click context menu — mute (checkmark), set default, move to sink (checkmark on current) +- [x] System tray (KSystemTray) — scroll wheel adjusts default sink volume, left-click popup/window toggle +- [x] Tray icon — SmallIcon("kmix")/("audio-volume-medium") fallback; muted/low/medium/high states +- [x] Tray popup (TmixPopup) — left-click, shows default output DeviceWidget + "Mixer" button, + auto-dismiss on outside click, positions above/below tray, stays on top +- [x] Recording tray icon — second KSystemTray icon when any input is active; + tooltip lists names of active recording streams +- [x] "Show all in one view" (no-tabs mode) — TQWidgetStack, rebuilds on toggle, + live device add/remove works in both modes +- [x] Show/hide individual tabs (Output, Input, Playback, Recording) via preferences +- [x] Preferences dialog — modeless (non-blocking), OK+Apply+Cancel, + General: dock in tray, popup on click, rec tray icon, confirm quit, scroll step + View: no-tabs toggle, show/hide per tab +- [x] Window auto-sizes to content (adjustSize on device add/remove), minimum 300×200 +- [x] Bottom row fixed 30px height — all device widgets align regardless of content + +### To do +- [x] Devices tab — PA card profile switcher (dropdown per card, set active profile) +- [ ] Switches tab (like KMix "Switches" view) + Enumerate PA card profiles + ports (pa_context_get_card_info_list). + Show as checkboxes/comboboxes: active profile, port retasking, IEC958 enable, + Auto-Mute Mode, etc. May need ALSA fallback for raw HDA switches not + surfaced by PulseAudio (snd-hda-intel ctl elements via alsa-lib). +- [ ] Profiles / sources management tab + List all PA sources and sinks with their active profile (e.g. "analog-stereo", + "hdmi-stereo-extra1"). Allow switching profiles via dropdown. + Show default sink/source with a star; click to set new default + (pa_context_set_default_sink / pa_context_set_default_source). + Subscribe to PA_SUBSCRIPTION_MASK_SERVER + CARD for live updates. +- [x] Tray right-click: Configure, About, Quit (standard TDE style — no functional items) +- [x] Remember window geometry across restarts + +## Big Refactor — Backend Abstraction +Goal: replace KMix entirely across all Trinity platforms (Linux PA/PW/ALSA, FreeBSD OSS, +legacy systems). Each backend is self-contained end-to-end: its own model, its own widgets. +The main window is a dumb shell. + +Architecture: + AbstractBackend (minimal interface) + - name() / open() / close() + - createMixerWidget(parent) → TQWidget* + - createDevicesWidget(parent)→ TQWidget* (null if not supported) + + PulseAudioBackend : AbstractBackend — current code, reorganised + OSSBackend : AbstractBackend — flat sliders, no streams + AlsaBackend : AbstractBackend — HDA controls, switches + PipeWireBackend : AbstractBackend — routing graph if ever needed + + MixerWindow shrinks to: tray, menu, prefs, geometry, "host what backend gives me." + Shared UI widgets (DeviceWidget, LevelMeter, BalanceKnob) become a toolkit + backends may use or ignore — not a framework they're forced into. + + Backend selected at startup via config key "Backend" (default: pulseaudio). + Build system conditionally compiles backends per platform. + +Why: the OSS→ALSA→PA retrofit history was painful. No backend should ever know +about another; no generic UI code should check backend capabilities via flags. +- [ ] L/R channel lock/unlock button — when unlocked, show two independent sliders + for left and right channels; when locked, they move together (current behavior) +- [ ] DCOP interface (get/set volume, mute, list devices) +- [ ] Dark/light theme awareness (follow TDE palette) +- [ ] Horizontal layout mode — device strips laid out left-to-right instead of + top-to-bottom; label above slider, level meter beside it. Toggle in View menu. +- [ ] Current mixer selector — dropdown or sub-menu to switch between PulseAudio + and raw ALSA hardware cards (like KMix "Current Mixer"). Each card gets its + own tab set; PA is the default. Enumerate via pa_context_get_card_info_list + for PA cards; snd_ctl_open for ALSA HDA cards. +- [ ] Popup config options (Settings → General): + Popup content: default output only (default) | all outputs | active playback streams + Show level meter in popup: yes/no (saves vertical space) + +## Settings / Polish (do later) +- [ ] Settings dialog: switch from KDialogBase Tabbed to IconList style (like KMix) — + big sidebar icons (General, View, etc.) with page content on the right. +- [ ] Settings dialog: Defaults button, Help button +- [ ] Settings dialog: grey out Apply until a change is actually made +- [ ] Show/hide toggles for tickmarks, labels, level meters (View tab) +- [ ] KMix-style classic tray widget — large tray area showing default sink volume slider + directly in the panel (not a popup), like KMix's docked mixer strip. + +## Distribution / Integration +- [ ] .desktop file + icon +- [ ] CMake install rules (bin, desktop, icon) +- [ ] PipeWire-native backend (currently works via PW's PA compatibility layer) +- [ ] Package as trinity-mixer diff --git a/src/main.cpp b/src/main.cpp index 6a8c6a8..e8e7d36 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,10 +1,13 @@ -#include <tdeapplication.h> +#include <kuniqueapplication.h> #include <tdecmdlineargs.h> #include <tdeaboutdata.h> #include <tdelocale.h> +#include <tdeconfig.h> +#include <tdeglobal.h> #include "model/pulsemodel.h" #include "ui/mixerwindow.h" +#include "ui/tmixapp.h" static TDECmdLineOptions options[] = { TDECmdLineLastOption }; @@ -20,16 +23,27 @@ int main( int argc, char **argv ) TDECmdLineArgs::init( argc, argv, &about ); TDECmdLineArgs::addCmdLineOptions( options ); - TDEApplication app; + KUniqueApplication::addCmdLineOptions(); - PulseModel model; - if ( !model.open() ) { - // TODO: show error dialog + if ( !KUniqueApplication::start() ) + return 0; + + TmixApp app; + app.disableSessionManagement(); + + PulseModel *model = new PulseModel; + if ( !model->open() ) { + delete model; return 1; } - MixerWindow win( &model ); - win.show(); + MixerWindow *win = new MixerWindow( model ); + app.setMainWindow( win ); + + TDEConfig *cfg = TDEGlobal::config(); + cfg->setGroup("General"); + if ( !cfg->readBoolEntry("DockInTray", true) ) + win->show(); return app.exec(); } diff --git a/src/model/audiodevice.h b/src/model/audiodevice.h index e753fc9..854ba35 100644 --- a/src/model/audiodevice.h +++ b/src/model/audiodevice.h @@ -21,12 +21,16 @@ public: virtual ~AudioDevice() {} virtual TQString name() const = 0; + virtual TQString iconName() const = 0; virtual Category category() const = 0; virtual int volume() const = 0; // 0-100 virtual bool muted() const = 0; + virtual int pan() const = 0; // -50 (left) .. 0 .. +50 (right) + virtual void setVolume( int v ) = 0; virtual void setMuted( bool m ) = 0; + virtual void setPan( int p ) = 0; // Creates the widget for this device. Caller takes ownership. virtual TQWidget *createWidget( TQWidget *parent ) = 0; @@ -34,5 +38,8 @@ public: signals: void volumeChanged( int v ); void muteChanged( bool m ); + void panChanged( int p ); + void levelChanged( float level ); void nameChanged( const TQString &name ); + void recordingActiveChanged( bool active ); }; diff --git a/src/model/pulsedevice.cpp b/src/model/pulsedevice.cpp index e0f84ab..de778b9 100644 --- a/src/model/pulsedevice.cpp +++ b/src/model/pulsedevice.cpp @@ -1,37 +1,168 @@ #include "pulsedevice.h" +#include "pulsemodel.h" #include "../ui/devicewidget.h" -#include <tqwidget.h> + +#include <tqapplication.h> +#include <string.h> + +// ---- peak-level event (PA thread → main thread) ----------------------------- + +static const int PA_LEVEL_EVENT = TQEvent::User + 2; + +struct PALevelEvent : public TQCustomEvent { + float level; + PALevelEvent( float l ) : TQCustomEvent(PA_LEVEL_EVENT), level(l) {} +}; + +// ---- ctor / dtor ------------------------------------------------------------ PulseDevice::PulseDevice( AudioDevice::Category cat, uint32_t paIndex, TQObject *parent ) : AudioDevice(parent), - m_category(cat), m_paIndex(paIndex), - m_volume(0), m_muted(false), - m_context(0), m_mainloop(0) + m_category(cat), m_paIndex(paIndex), m_sinkIndex(PA_INVALID_INDEX), + m_volume(0), m_muted(false), m_pan(0), m_channels(2), + m_context(0), m_mainloop(0), + m_monitorStream(0), m_model(0), m_recordingCount(0) +{ +} + +PulseDevice::~PulseDevice() { + // detach() should have been called by PulseModel before destruction; + // this is a safety net for cases where it wasn't. + if ( m_monitorStream ) { + pa_stream_set_read_callback( m_monitorStream, 0, 0 ); + pa_stream_unref( m_monitorStream ); + m_monitorStream = 0; + } } +// ---- context wiring --------------------------------------------------------- + void PulseDevice::setPAContext( pa_context *ctx, pa_threaded_mainloop *mainloop ) { m_context = ctx; m_mainloop = mainloop; + startMonitoring(); +} + +void PulseDevice::detach() +{ + if ( m_monitorStream && m_mainloop ) { + pa_threaded_mainloop_lock( m_mainloop ); + pa_stream_set_read_callback( m_monitorStream, 0, 0 ); + pa_stream_disconnect( m_monitorStream ); + pa_stream_unref( m_monitorStream ); + m_monitorStream = 0; + pa_threaded_mainloop_unlock( m_mainloop ); + } + m_context = 0; + m_mainloop = 0; } -void PulseDevice::update( const TQString &name, int volume, bool muted ) +// ---- update ----------------------------------------------------------------- + +void PulseDevice::update( const TQString &name, int volume, bool muted, + uint8_t channels, int pan, + const TQString &monitorName, const TQString &iconName ) { bool nameChange = ( name != m_name ); bool volumeChange = ( volume != m_volume ); bool muteChange = ( muted != m_muted ); + bool panChange = ( pan != m_pan ); + + m_name = name; + m_iconName = iconName; + m_volume = volume; + m_muted = muted; + m_channels = channels ? channels : 2; + m_pan = pan; - m_name = name; - m_volume = volume; - m_muted = muted; + if ( m_monitorName.isEmpty() && !monitorName.isEmpty() ) { + m_monitorName = monitorName; + if ( m_context ) startMonitoring(); + } if ( nameChange ) emit nameChanged( m_name ); if ( volumeChange ) emit volumeChanged( m_volume ); if ( muteChange ) emit muteChanged( m_muted ); + if ( panChange ) emit panChanged( m_pan ); } -// ---- write back to PA ------------------------------------------------------- +// ---- peak monitoring -------------------------------------------------------- + +void PulseDevice::startMonitoring() +{ + if ( !m_context || !m_mainloop || m_monitorName.isEmpty() || m_monitorStream ) + return; + + pa_sample_spec spec; + spec.format = PA_SAMPLE_FLOAT32NE; + spec.rate = 25; + spec.channels = 1; + + pa_threaded_mainloop_lock( m_mainloop ); + + pa_proplist *props = pa_proplist_new(); + pa_proplist_sets( props, PA_PROP_MEDIA_ROLE, "abstract" ); + m_monitorStream = pa_stream_new_with_proplist( m_context, "tmix-peak", &spec, 0, props ); + pa_proplist_free( props ); + if ( m_monitorStream ) { + pa_stream_set_read_callback( m_monitorStream, monitorReadCb, this ); + + // For sink inputs, filter peak detection to just this stream. + if ( m_category == Playback ) + pa_stream_set_monitor_stream( m_monitorStream, m_paIndex ); + + pa_buffer_attr attr; + memset( &attr, 0xff, sizeof(attr) ); + attr.fragsize = sizeof(float); + + pa_stream_connect_record( m_monitorStream, + m_monitorName.utf8().data(), &attr, + (pa_stream_flags_t)(PA_STREAM_PEAK_DETECT | + PA_STREAM_ADJUST_LATENCY | + PA_STREAM_DONT_MOVE) ); + } + + pa_threaded_mainloop_unlock( m_mainloop ); +} + +void PulseDevice::monitorReadCb( pa_stream *s, size_t /*nbytes*/, void *userdata ) +{ + PulseDevice *self = static_cast<PulseDevice*>(userdata); + const void *data = 0; + size_t len = 0; + + if ( pa_stream_peek( s, &data, &len ) < 0 ) return; + + float level = 0.0f; + if ( data && len >= sizeof(float) ) + level = *static_cast<const float*>(data); + if ( level < 0.0f ) level = 0.0f; + if ( level > 1.0f ) level = 1.0f; + + pa_stream_drop( s ); + + TQApplication::postEvent( self, new PALevelEvent(level) ); +} + +void PulseDevice::customEvent( TQCustomEvent *e ) +{ + if ( e->type() == PA_LEVEL_EVENT ) + emit levelChanged( static_cast<PALevelEvent*>(e)->level ); +} + +void PulseDevice::adjustRecordingCount( int delta ) +{ + bool wasBefore = ( m_recordingCount > 0 ); + m_recordingCount += delta; + if ( m_recordingCount < 0 ) m_recordingCount = 0; + bool isNow = ( m_recordingCount > 0 ); + if ( isNow != wasBefore ) + emit recordingActiveChanged( isNow ); +} + +// ---- helpers ---------------------------------------------------------------- static pa_volume_t percentToPA( int pct ) { @@ -40,29 +171,60 @@ static pa_volume_t percentToPA( int pct ) return (pa_volume_t)( (double)pct / 100.0 * PA_VOLUME_NORM + 0.5 ); } +static pa_cvolume buildCVolume( uint8_t channels, pa_volume_t vol, int pan ) +{ + pa_cvolume cv; + cv.channels = channels; + if ( channels >= 2 ) { + double lf = ( pan >= 0 ) ? ( 50.0 - pan ) / 50.0 : 1.0; + double rf = ( pan <= 0 ) ? ( 50.0 + pan ) / 50.0 : 1.0; + cv.values[0] = (pa_volume_t)( vol * lf ); + cv.values[1] = (pa_volume_t)( vol * rf ); + for ( uint8_t i = 2; i < channels; i++ ) + cv.values[i] = vol; + } else { + pa_cvolume_set( &cv, channels, vol ); + } + return cv; +} + +// ---- write back to PA ------------------------------------------------------- + void PulseDevice::setVolume( int v ) { if ( !m_context ) return; v = v < 0 ? 0 : v > 100 ? 100 : v; + m_volume = v; + emit volumeChanged( v ); - pa_cvolume cv; - pa_cvolume_set( &cv, 2, percentToPA(v) ); + pa_cvolume cv = buildCVolume( m_channels, percentToPA(v), m_pan ); + pa_threaded_mainloop_lock( m_mainloop ); + pa_operation *op = 0; + switch ( m_category ) { + case Output: op = pa_context_set_sink_volume_by_index( m_context, m_paIndex, &cv, 0, 0 ); break; + case Input: op = pa_context_set_source_volume_by_index( m_context, m_paIndex, &cv, 0, 0 ); break; + case Playback: op = pa_context_set_sink_input_volume( m_context, m_paIndex, &cv, 0, 0 ); break; + case Recording: op = pa_context_set_source_output_volume( m_context, m_paIndex, &cv, 0, 0 ); break; + } + if ( op ) pa_operation_unref( op ); + pa_threaded_mainloop_unlock( m_mainloop ); +} +void PulseDevice::setPan( int p ) +{ + if ( !m_context ) return; + p = p < -50 ? -50 : p > 50 ? 50 : p; + m_pan = p; + emit panChanged( p ); + + pa_cvolume cv = buildCVolume( m_channels, percentToPA(m_volume), p ); pa_threaded_mainloop_lock( m_mainloop ); pa_operation *op = 0; switch ( m_category ) { - case Output: - op = pa_context_set_sink_volume_by_index( m_context, m_paIndex, &cv, 0, 0 ); - break; - case Input: - op = pa_context_set_source_volume_by_index( m_context, m_paIndex, &cv, 0, 0 ); - break; - case Playback: - op = pa_context_set_sink_input_volume( m_context, m_paIndex, &cv, 0, 0 ); - break; - case Recording: - op = pa_context_set_source_output_volume( m_context, m_paIndex, &cv, 0, 0 ); - break; + case Output: op = pa_context_set_sink_volume_by_index( m_context, m_paIndex, &cv, 0, 0 ); break; + case Input: op = pa_context_set_source_volume_by_index( m_context, m_paIndex, &cv, 0, 0 ); break; + case Playback: op = pa_context_set_sink_input_volume( m_context, m_paIndex, &cv, 0, 0 ); break; + case Recording: op = pa_context_set_source_output_volume( m_context, m_paIndex, &cv, 0, 0 ); break; } if ( op ) pa_operation_unref( op ); pa_threaded_mainloop_unlock( m_mainloop ); @@ -71,23 +233,14 @@ void PulseDevice::setVolume( int v ) void PulseDevice::setMuted( bool m ) { if ( !m_context ) return; - pa_threaded_mainloop_lock( m_mainloop ); pa_operation *op = 0; int mute = m ? 1 : 0; switch ( m_category ) { - case Output: - op = pa_context_set_sink_mute_by_index( m_context, m_paIndex, mute, 0, 0 ); - break; - case Input: - op = pa_context_set_source_mute_by_index( m_context, m_paIndex, mute, 0, 0 ); - break; - case Playback: - op = pa_context_set_sink_input_mute( m_context, m_paIndex, mute, 0, 0 ); - break; - case Recording: - op = pa_context_set_source_output_mute( m_context, m_paIndex, mute, 0, 0 ); - break; + case Output: op = pa_context_set_sink_mute_by_index( m_context, m_paIndex, mute, 0, 0 ); break; + case Input: op = pa_context_set_source_mute_by_index( m_context, m_paIndex, mute, 0, 0 ); break; + case Playback: op = pa_context_set_sink_input_mute( m_context, m_paIndex, mute, 0, 0 ); break; + case Recording: op = pa_context_set_source_output_mute( m_context, m_paIndex, mute, 0, 0 ); break; } if ( op ) pa_operation_unref( op ); pa_threaded_mainloop_unlock( m_mainloop ); @@ -95,7 +248,7 @@ void PulseDevice::setMuted( bool m ) TQWidget *PulseDevice::createWidget( TQWidget *parent ) { - return new DeviceWidget( this, parent ); + return new DeviceWidget( this, m_model, parent ); } #include "pulsedevice.moc" diff --git a/src/model/pulsedevice.h b/src/model/pulsedevice.h index fbdf985..e974508 100644 --- a/src/model/pulsedevice.h +++ b/src/model/pulsedevice.h @@ -3,38 +3,68 @@ #include <pulse/pulseaudio.h> #include "audiodevice.h" +class PulseModel; + class PulseDevice : public AudioDevice { TQ_OBJECT public: PulseDevice( AudioDevice::Category cat, uint32_t paIndex, TQObject *parent = 0 ); - ~PulseDevice() {} + ~PulseDevice(); TQString name() const { return m_name; } + TQString iconName() const { return m_iconName; } Category category() const { return m_category; } int volume() const { return m_volume; } bool muted() const { return m_muted; } - uint32_t paIndex() const { return m_paIndex; } + int pan() const { return m_pan; } + uint32_t paIndex() const { return m_paIndex; } + uint32_t sinkIndex() const { return m_sinkIndex; } + TQString monitorName() const { return m_monitorName; } + TQString paName() const { return m_paName; } + + void setPaName( const TQString &n ) { m_paName = n; } + void setSinkIndex( uint32_t idx ) { m_sinkIndex = idx; } + void setModel( PulseModel *m ) { m_model = m; } + void adjustRecordingCount( int delta ); void setVolume( int v ); void setMuted( bool m ); + void setPan( int p ); - // Called by PulseModel when PA reports an update — updates in place and emits signals. - void update( const TQString &name, int volume, bool muted ); + void update( const TQString &name, int volume, bool muted, + uint8_t channels, int pan, + const TQString &monitorName, const TQString &iconName ); TQWidget *createWidget( TQWidget *parent ); - // PA context needed to write volume back — set once by PulseModel after construction. void setPAContext( pa_context *ctx, pa_threaded_mainloop *mainloop ); + void detach(); // called by PulseModel before tearing down the mainloop + +protected: + void customEvent( TQCustomEvent *e ); private: + void startMonitoring(); + static void monitorReadCb( pa_stream *s, size_t nbytes, void *userdata ); + AudioDevice::Category m_category; uint32_t m_paIndex; + uint32_t m_sinkIndex; TQString m_name; - int m_volume; // 0-100 + TQString m_iconName; + int m_volume; bool m_muted; + int m_pan; + uint8_t m_channels; pa_context *m_context; pa_threaded_mainloop *m_mainloop; + + TQString m_monitorName; + pa_stream *m_monitorStream; + TQString m_paName; + PulseModel *m_model; + int m_recordingCount; }; diff --git a/src/model/pulsemodel.cpp b/src/model/pulsemodel.cpp index de745bc..a677edc 100644 --- a/src/model/pulsemodel.cpp +++ b/src/model/pulsemodel.cpp @@ -3,8 +3,17 @@ #include <tqapplication.h> -// Custom event posted from the PA thread to the main thread. -static const int PA_EVENT = TQEvent::User + 1; +// Custom events posted from the PA thread to the main thread. +static const int PA_EVENT = TQEvent::User + 1; +static const int PA_SERVER_EVENT = TQEvent::User + 3; +static const int PA_CARD_EVENT = TQEvent::User + 4; + +struct PAServerEvent : public TQCustomEvent { + TQString defaultSinkName; + TQString defaultSourceName; + PAServerEvent( const TQString &sink, const TQString &source ) + : TQCustomEvent(PA_SERVER_EVENT), defaultSinkName(sink), defaultSourceName(source) {} +}; struct PAEvent : public TQCustomEvent { enum Kind { DeviceAdded, DeviceRemoved, DeviceUpdated }; @@ -14,19 +23,123 @@ struct PAEvent : public TQCustomEvent { TQString name; int volume; bool muted; + uint8_t channels; + int pan; + TQString monitorName; + TQString iconName; + TQString paName; // raw PA name (sinks/sources only, for default tracking) + uint32_t parentIndex; // for Playback: parent sink PA index PAEvent( Kind k, AudioDevice::Category c, uint32_t idx, - const TQString &n = TQString(), int vol = 0, bool m = false ) + const TQString &n = TQString(), int vol = 0, bool m = false, + uint8_t ch = 2, int p = 0, const TQString &mon = TQString(), + const TQString &icon = TQString(), uint32_t parent = PA_INVALID_INDEX, + const TQString &pname = TQString() ) : TQCustomEvent(PA_EVENT), kind(k), cat(c), paIndex(idx), - name(n), volume(vol), muted(m) {} + name(n), volume(vol), muted(m), channels(ch), pan(p), + monitorName(mon), iconName(icon), paName(pname), parentIndex(parent) {} +}; + +struct PACardEvent : public TQCustomEvent { + struct Profile { TQString name; TQString description; bool available; }; + struct Port { + TQString name; TQString description; + int available; int direction; uint32_t type; TQString availGroup; + }; + bool removed; + uint32_t index; + TQString name; + TQString description; + TQString activeProfile; + TQString vendor; + TQString product; + TQString formFactor; + TQString busType; + TQValueList<Profile> profiles; + TQValueList<Port> ports; + + explicit PACardEvent( uint32_t idx ) + : TQCustomEvent(PA_CARD_EVENT), removed(true), index(idx) {} + PACardEvent( uint32_t idx, const TQString &n, const TQString &d, const TQString &ap ) + : TQCustomEvent(PA_CARD_EVENT), removed(false), index(idx), + name(n), description(d), activeProfile(ap) {} }; // ---- helpers ---------------------------------------------------------------- +static bool isGenericMediaName( const TQString &s ) +{ + TQString l = s.lower().stripWhiteSpace(); + return l == "audio stream" || l == "alsa playback" || l == "playback" + || l == "audio output" || l == "output" || l.isEmpty(); +} + +static TQString paCleanAppName( const char *raw ) +{ + if ( !raw || !*raw ) return TQString(); + TQString s = TQString::fromUtf8( raw ); + + // Strip "ALSA plug-in [Foo]" wrapper → "Foo" + int lb = s.find('['), rb = s.findRev(']'); + if ( lb != -1 && rb > lb ) + s = s.mid( lb + 1, rb - lb - 1 ).stripWhiteSpace(); + + // Strip trailing "app" (amarokapp → amarok), then capitalise first letter + if ( s.endsWith("app") && s.length() > 3 ) + s = s.left( s.length() - 3 ); + if ( !s.isEmpty() ) + s[0] = s[0].upper(); + + return s; +} + +static TQString paStreamName( pa_proplist *pl, const char *fallback ) +{ + TQString appName = paCleanAppName( pa_proplist_gets(pl, PA_PROP_APPLICATION_NAME) ); + const char *mn = pa_proplist_gets(pl, PA_PROP_MEDIA_NAME); + TQString mediaName = mn ? TQString::fromUtf8(mn).stripWhiteSpace() : TQString(); + + if ( !appName.isEmpty() && !isGenericMediaName(mediaName) && !mediaName.isEmpty() ) + return appName + " - " + mediaName; + if ( !appName.isEmpty() ) + return appName; + if ( !isGenericMediaName(mediaName) && !mediaName.isEmpty() ) + return mediaName; + return fallback ? TQString::fromUtf8(fallback) : TQString(); +} + +static TQString paAppIcon( pa_proplist *pl ) +{ + const char *s; + if ( (s = pa_proplist_gets(pl, PA_PROP_APPLICATION_ICON_NAME)) && *s ) + return TQString::fromUtf8(s); + if ( (s = pa_proplist_gets(pl, "application.process.binary")) && *s ) { + TQString bin = TQString::fromUtf8(s).lower(); + // Some TDE apps register binary as "fooapp" but icon as "foo" + if ( bin.endsWith("app") ) + return bin.left( bin.length() - 3 ); + return bin; + } + if ( (s = pa_proplist_gets(pl, PA_PROP_APPLICATION_NAME)) && *s ) + return TQString::fromUtf8(s).lower(); + return TQString(); +} + static int paVolumeToPercent( const pa_cvolume &cv ) { - pa_volume_t avg = pa_cvolume_avg( &cv ); - return (int)( (double)avg / PA_VOLUME_NORM * 100.0 + 0.5 ); + pa_volume_t mx = pa_cvolume_max( &cv ); + return (int)( (double)mx / PA_VOLUME_NORM * 100.0 + 0.5 ); +} + +// Derive pan -50..+50 from the first two channels of a cvolume. +static int cvolumeToPan( const pa_cvolume &cv ) +{ + if ( cv.channels < 2 ) return 0; + double l = cv.values[0]; + double r = cv.values[1]; + double mx = ( l > r ) ? l : r; + if ( mx < 1.0 ) return 0; + return (int)( ( r - l ) / mx * 50.0 ); } // ---- ctor / dtor ------------------------------------------------------------ @@ -70,6 +183,12 @@ bool PulseModel::open() void PulseModel::close() { + // Detach monitor streams on all devices before tearing down the mainloop. + TQPtrList<PulseDevice> *allLists[4] = { &m_sinks, &m_sources, &m_sinkInputs, &m_sourceOutputs }; + for ( int i = 0; i < 4; i++ ) + for ( TQPtrListIterator<PulseDevice> it(*allLists[i]); *it; ++it ) + (*it)->detach(); + if ( m_context ) { pa_threaded_mainloop_lock( m_mainloop ); pa_context_disconnect( m_context ); @@ -119,8 +238,11 @@ void PulseModel::contextStateCb( pa_context *c, void *userdata ) PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SOURCE | PA_SUBSCRIPTION_MASK_SINK_INPUT | - PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT ), + PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT | + PA_SUBSCRIPTION_MASK_SERVER | + PA_SUBSCRIPTION_MASK_CARD ), 0, 0 ); + pa_operation_unref( pa_context_get_server_info( c, serverInfoCb, self ) ); self->enumerateAll(); break; case PA_CONTEXT_FAILED: @@ -138,16 +260,32 @@ void PulseModel::enumerateAll() pa_operation_unref( pa_context_get_source_info_list( m_context, sourceInfoCb, this ) ); pa_operation_unref( pa_context_get_sink_input_info_list( m_context, sinkInputInfoCb, this ) ); pa_operation_unref( pa_context_get_source_output_info_list( m_context, sourceOutputInfoCb, this ) ); + pa_operation_unref( pa_context_get_card_info_list( m_context, cardInfoCb, this ) ); +} + +void PulseModel::serverInfoCb( pa_context *, const pa_server_info *info, void *userdata ) +{ + if ( !info ) return; + PulseModel *self = static_cast<PulseModel *>(userdata); + TQApplication::postEvent( self, new PAServerEvent( + TQString::fromUtf8( info->default_sink_name ? info->default_sink_name : "" ), + TQString::fromUtf8( info->default_source_name ? info->default_source_name : "" ) ) ); } void PulseModel::sinkInfoCb( pa_context *, const pa_sink_info *info, int eol, void *userdata ) { if ( eol || !info ) return; PulseModel *self = static_cast<PulseModel *>(userdata); - TQApplication::postEvent( self, new PAEvent( + PAEvent *ev = new PAEvent( PAEvent::DeviceAdded, AudioDevice::Output, info->index, TQString::fromUtf8( info->description ), - paVolumeToPercent( info->volume ), info->mute != 0 ) ); + paVolumeToPercent( info->volume ), info->mute != 0, + info->volume.channels, cvolumeToPan( info->volume ), + TQString::fromUtf8( info->monitor_source_name ), + TQString::fromUtf8( pa_proplist_gets( info->proplist, PA_PROP_DEVICE_ICON_NAME ) ? : "" ), + PA_INVALID_INDEX, + TQString::fromUtf8( info->name ) ); + TQApplication::postEvent( self, ev ); } void PulseModel::sourceInfoCb( pa_context *, const pa_source_info *info, int eol, void *userdata ) @@ -159,7 +297,12 @@ void PulseModel::sourceInfoCb( pa_context *, const pa_source_info *info, int eol TQApplication::postEvent( self, new PAEvent( PAEvent::DeviceAdded, AudioDevice::Input, info->index, TQString::fromUtf8( info->description ), - paVolumeToPercent( info->volume ), info->mute != 0 ) ); + paVolumeToPercent( info->volume ), info->mute != 0, + info->volume.channels, cvolumeToPan( info->volume ), + TQString::fromUtf8( info->name ), + TQString::fromUtf8( pa_proplist_gets( info->proplist, PA_PROP_DEVICE_ICON_NAME ) ? : "" ), + PA_INVALID_INDEX, + TQString::fromUtf8( info->name ) ) ); } void PulseModel::sinkInputInfoCb( pa_context *, const pa_sink_input_info *info, int eol, void *userdata ) @@ -168,18 +311,27 @@ void PulseModel::sinkInputInfoCb( pa_context *, const pa_sink_input_info *info, PulseModel *self = static_cast<PulseModel *>(userdata); TQApplication::postEvent( self, new PAEvent( PAEvent::DeviceAdded, AudioDevice::Playback, info->index, - TQString::fromUtf8( info->name ), - paVolumeToPercent( info->volume ), info->mute != 0 ) ); + paStreamName( info->proplist, info->name ), + paVolumeToPercent( info->volume ), info->mute != 0, + info->volume.channels, cvolumeToPan( info->volume ), + TQString(), // monitor name filled in by customEvent from parent sink + paAppIcon( info->proplist ), + info->sink ) ); // parent sink index for peak monitoring } void PulseModel::sourceOutputInfoCb( pa_context *, const pa_source_output_info *info, int eol, void *userdata ) { if ( eol || !info ) return; + // Filter out our own peak-monitor streams. + if ( info->name && strcmp(info->name, "tmix-peak") == 0 ) return; PulseModel *self = static_cast<PulseModel *>(userdata); TQApplication::postEvent( self, new PAEvent( PAEvent::DeviceAdded, AudioDevice::Recording, info->index, - TQString::fromUtf8( info->name ), - paVolumeToPercent( info->volume ), info->mute != 0 ) ); + paStreamName( info->proplist, info->name ), + paVolumeToPercent( info->volume ), info->mute != 0, + info->volume.channels, cvolumeToPan( info->volume ), + TQString(), paAppIcon( info->proplist ), + info->source ) ); // parentIndex = source being recorded from } void PulseModel::subscribeCb( pa_context *c, pa_subscription_event_type_t t, uint32_t idx, void *userdata ) @@ -189,6 +341,21 @@ void PulseModel::subscribeCb( pa_context *c, pa_subscription_event_type_t t, uin pa_subscription_event_type_t facility = (pa_subscription_event_type_t)( t & PA_SUBSCRIPTION_EVENT_FACILITY_MASK ); pa_subscription_event_type_t evtype = (pa_subscription_event_type_t)( t & PA_SUBSCRIPTION_EVENT_TYPE_MASK ); + if ( facility == PA_SUBSCRIPTION_EVENT_SERVER ) { + pa_operation_unref( pa_context_get_server_info( c, serverInfoCb, self ) ); + return; + } + + if ( facility == PA_SUBSCRIPTION_EVENT_CARD ) { + if ( evtype == PA_SUBSCRIPTION_EVENT_REMOVE ) { + TQApplication::postEvent( self, new PACardEvent( idx ) ); + } else { + pa_operation *op = pa_context_get_card_info_by_index( c, idx, cardInfoCb, self ); + if ( op ) pa_operation_unref( op ); + } + return; + } + AudioDevice::Category cat; switch ( facility ) { case PA_SUBSCRIPTION_EVENT_SINK: cat = AudioDevice::Output; break; @@ -223,10 +390,134 @@ void PulseModel::subscribeCb( pa_context *c, pa_subscription_event_type_t t, uin if ( op ) pa_operation_unref( op ); } +void PulseModel::cardInfoCb( pa_context *, const pa_card_info *info, int eol, void *userdata ) +{ + if ( eol || !info ) return; + PulseModel *self = static_cast<PulseModel *>(userdata); + + const char *desc = pa_proplist_gets( info->proplist, PA_PROP_DEVICE_DESCRIPTION ); + const char *vendor = pa_proplist_gets( info->proplist, PA_PROP_DEVICE_VENDOR_NAME ); + const char *product = pa_proplist_gets( info->proplist, PA_PROP_DEVICE_PRODUCT_NAME ); + const char *ff = pa_proplist_gets( info->proplist, PA_PROP_DEVICE_FORM_FACTOR ); + const char *bus = pa_proplist_gets( info->proplist, "device.bus" ); + + PACardEvent *ev = new PACardEvent( + info->index, + TQString::fromUtf8( info->name ), + TQString::fromUtf8( desc ? desc : info->name ), + info->active_profile2 ? TQString::fromUtf8( info->active_profile2->name ) : TQString() ); + + ev->vendor = TQString::fromUtf8( vendor ? vendor : "" ); + ev->product = TQString::fromUtf8( product ? product : "" ); + ev->formFactor = TQString::fromUtf8( ff ? ff : "" ); + ev->busType = TQString::fromUtf8( bus ? bus : "" ); + + for ( uint32_t i = 0; info->profiles2 && info->profiles2[i]; ++i ) { + PACardEvent::Profile p; + p.name = TQString::fromUtf8( info->profiles2[i]->name ); + p.description = TQString::fromUtf8( info->profiles2[i]->description ); + p.available = info->profiles2[i]->available != 0; + ev->profiles.append( p ); + } + + for ( uint32_t i = 0; i < info->n_ports; ++i ) { + PACardEvent::Port port; + port.name = TQString::fromUtf8( info->ports[i]->name ); + port.description= TQString::fromUtf8( info->ports[i]->description ); + port.available = info->ports[i]->available; + port.direction = info->ports[i]->direction; + port.type = info->ports[i]->type; + if ( info->ports[i]->availability_group ) + port.availGroup = TQString::fromUtf8( info->ports[i]->availability_group ); + ev->ports.append( port ); + } + + TQApplication::postEvent( self, ev ); +} + // ---- main thread event handler ---------------------------------------------- void PulseModel::customEvent( TQCustomEvent *e ) { + if ( e->type() == PA_CARD_EVENT ) { + PACardEvent *ev = static_cast<PACardEvent *>(e); + if ( ev->removed ) { + for ( TQValueList<PulseCardInfo>::Iterator it = m_cards.begin(); + it != m_cards.end(); ++it ) { + if ( it->index == ev->index ) { + m_cards.remove( it ); + emit cardRemoved( ev->index ); + return; + } + } + return; + } + PulseCardInfo *existing = findCard( ev->index ); + if ( existing ) { + existing->name = ev->name; + existing->description = ev->description; + existing->activeProfile = ev->activeProfile; + existing->vendor = ev->vendor; + existing->product = ev->product; + existing->formFactor = ev->formFactor; + existing->busType = ev->busType; + existing->profiles.clear(); + existing->ports.clear(); + for ( TQValueList<PACardEvent::Profile>::Iterator it = ev->profiles.begin(); + it != ev->profiles.end(); ++it ) { + PulseCardProfile p; p.name = it->name; p.description = it->description; p.available = it->available; + existing->profiles.append( p ); + } + for ( TQValueList<PACardEvent::Port>::Iterator it = ev->ports.begin(); + it != ev->ports.end(); ++it ) { + PulseCardPort port; + port.name = it->name; port.description = it->description; + port.available = it->available; port.direction = it->direction; + port.type = it->type; port.availabilityGroup = it->availGroup; + existing->ports.append( port ); + } + emit cardUpdated( ev->index ); + } else { + PulseCardInfo info; + info.index = ev->index; + info.name = ev->name; + info.description = ev->description; + info.activeProfile = ev->activeProfile; + info.vendor = ev->vendor; + info.product = ev->product; + info.formFactor = ev->formFactor; + info.busType = ev->busType; + for ( TQValueList<PACardEvent::Profile>::Iterator it = ev->profiles.begin(); + it != ev->profiles.end(); ++it ) { + PulseCardProfile p; p.name = it->name; p.description = it->description; p.available = it->available; + info.profiles.append( p ); + } + for ( TQValueList<PACardEvent::Port>::Iterator it = ev->ports.begin(); + it != ev->ports.end(); ++it ) { + PulseCardPort port; + port.name = it->name; port.description = it->description; + port.available = it->available; port.direction = it->direction; + port.type = it->type; port.availabilityGroup = it->availGroup; + info.ports.append( port ); + } + m_cards.append( info ); + emit cardAdded( ev->index ); + } + return; + } + + if ( e->type() == PA_SERVER_EVENT ) { + PAServerEvent *ev = static_cast<PAServerEvent *>(e); + if ( ev->defaultSinkName != m_defaultSinkName ) { + m_defaultSinkName = ev->defaultSinkName; + emit defaultOutputChanged( defaultOutput() ); + } + if ( ev->defaultSourceName != m_defaultSourceName ) { + m_defaultSourceName = ev->defaultSourceName; + emit defaultInputChanged( defaultInput() ); + } + return; + } if ( e->type() != PA_EVENT ) return; PAEvent *ev = static_cast<PAEvent *>(e); @@ -241,25 +532,89 @@ void PulseModel::customEvent( TQCustomEvent *e ) if ( ev->kind == PAEvent::DeviceRemoved ) { PulseDevice *dev = findDevice( *list, ev->paIndex ); if ( dev ) { + // Remove from name maps if present. + TQMap<TQString,PulseDevice*>::Iterator it; + for ( it = m_sinksByName.begin(); it != m_sinksByName.end(); ++it ) { + if ( it.data() == dev ) { m_sinksByName.remove(it); break; } + } + for ( it = m_sourcesByName.begin(); it != m_sourcesByName.end(); ++it ) { + if ( it.data() == dev ) { m_sourcesByName.remove(it); break; } + } + // If a recording stream ended, decrement its parent source's count. + if ( ev->cat == AudioDevice::Recording ) { + TQMap<uint32_t,uint32_t>::Iterator si = m_sourceOutputToSource.find( ev->paIndex ); + if ( si != m_sourceOutputToSource.end() ) { + PulseDevice *src = findDevice( m_sources, si.data() ); + if ( src ) src->adjustRecordingCount( -1 ); + m_sourceOutputToSource.remove( si ); + } + } emit deviceRemoved( dev ); - list->remove( dev ); // autoDelete=true, so dev is deleted here + list->remove( dev ); } return; } // DeviceAdded or DeviceUpdated (both come through the same info callbacks). + // For Playback streams, resolve the parent sink's monitor source name so the + // level meter can attach a peak-detect stream to just this sink input. + TQString monitorName = ev->monitorName; + if ( ev->cat == AudioDevice::Playback && monitorName.isEmpty() + && ev->parentIndex != PA_INVALID_INDEX ) { + PulseDevice *sink = findDevice( m_sinks, ev->parentIndex ); + if ( sink ) monitorName = sink->monitorName(); + } + PulseDevice *dev = findDevice( *list, ev->paIndex ); if ( dev ) { - dev->update( ev->name, ev->volume, ev->muted ); + dev->update( ev->name, ev->volume, ev->muted, ev->channels, ev->pan, monitorName, ev->iconName ); + if ( ev->cat == AudioDevice::Playback && ev->parentIndex != PA_INVALID_INDEX ) + dev->setSinkIndex( ev->parentIndex ); } else { dev = new PulseDevice( ev->cat, ev->paIndex, this ); + dev->setModel( this ); dev->setPAContext( m_context, m_mainloop ); - dev->update( ev->name, ev->volume, ev->muted ); + dev->update( ev->name, ev->volume, ev->muted, ev->channels, ev->pan, monitorName, ev->iconName ); + if ( ev->cat == AudioDevice::Playback && ev->parentIndex != PA_INVALID_INDEX ) + dev->setSinkIndex( ev->parentIndex ); + if ( !ev->paName.isEmpty() ) { + dev->setPaName( ev->paName ); + } list->append( dev ); + if ( !ev->paName.isEmpty() ) { + if ( ev->cat == AudioDevice::Output ) + m_sinksByName.insert( ev->paName, dev ); + else if ( ev->cat == AudioDevice::Input ) + m_sourcesByName.insert( ev->paName, dev ); + } emit deviceAdded( dev ); + if ( ev->cat == AudioDevice::Output && ev->paName == m_defaultSinkName ) + emit defaultOutputChanged( dev ); + if ( ev->cat == AudioDevice::Input && ev->paName == m_defaultSourceName ) + emit defaultInputChanged( dev ); + // Track which source a new recording stream is attached to. + if ( ev->cat == AudioDevice::Recording && ev->parentIndex != PA_INVALID_INDEX ) { + m_sourceOutputToSource.insert( ev->paIndex, ev->parentIndex ); + PulseDevice *src = findDevice( m_sources, ev->parentIndex ); + if ( src ) src->adjustRecordingCount( +1 ); + } } } +AudioDevice *PulseModel::defaultOutput() const +{ + if ( m_sinksByName.contains(m_defaultSinkName) ) + return m_sinksByName[m_defaultSinkName]; + return 0; +} + +AudioDevice *PulseModel::defaultInput() const +{ + if ( m_sourcesByName.contains(m_defaultSourceName) ) + return m_sourcesByName[m_defaultSourceName]; + return 0; +} + PulseDevice *PulseModel::findDevice( TQPtrList<PulseDevice> &list, uint32_t paIndex ) { for ( TQPtrListIterator<PulseDevice> it(list); *it; ++it ) @@ -268,4 +623,66 @@ PulseDevice *PulseModel::findDevice( TQPtrList<PulseDevice> &list, uint32_t paIn return 0; } +// ---- operations called from UI (main thread) --------------------------------- + +void PulseModel::setDefaultOutput( AudioDevice *dev ) +{ + PulseDevice *pd = dynamic_cast<PulseDevice*>(dev); + if ( !pd || pd->paName().isEmpty() || !m_context || !m_mainloop ) return; + pa_threaded_mainloop_lock( m_mainloop ); + pa_operation *op = pa_context_set_default_sink( m_context, pd->paName().utf8().data(), 0, 0 ); + if ( op ) pa_operation_unref( op ); + pa_threaded_mainloop_unlock( m_mainloop ); +} + +void PulseModel::setDefaultInput( AudioDevice *dev ) +{ + PulseDevice *pd = dynamic_cast<PulseDevice*>(dev); + if ( !pd || pd->paName().isEmpty() || !m_context || !m_mainloop ) return; + pa_threaded_mainloop_lock( m_mainloop ); + pa_operation *op = pa_context_set_default_source( m_context, pd->paName().utf8().data(), 0, 0 ); + if ( op ) pa_operation_unref( op ); + pa_threaded_mainloop_unlock( m_mainloop ); +} + +void PulseModel::moveSinkInputToSink( AudioDevice *playbackDev, AudioDevice *outputDev ) +{ + PulseDevice *si = dynamic_cast<PulseDevice*>(playbackDev); + PulseDevice *sink = dynamic_cast<PulseDevice*>(outputDev); + if ( !si || !sink || !m_context || !m_mainloop ) return; + pa_threaded_mainloop_lock( m_mainloop ); + pa_operation *op = pa_context_move_sink_input_by_index( + m_context, si->paIndex(), sink->paIndex(), 0, 0 ); + if ( op ) pa_operation_unref( op ); + pa_threaded_mainloop_unlock( m_mainloop ); +} + +const PulseCardInfo *PulseModel::card( uint32_t index ) const +{ + for ( TQValueList<PulseCardInfo>::ConstIterator it = m_cards.begin(); + it != m_cards.end(); ++it ) + if ( (*it).index == index ) + return &(*it); + return 0; +} + +PulseCardInfo *PulseModel::findCard( uint32_t index ) +{ + for ( TQValueList<PulseCardInfo>::Iterator it = m_cards.begin(); + it != m_cards.end(); ++it ) + if ( it->index == index ) + return &(*it); + return 0; +} + +void PulseModel::setCardProfile( uint32_t cardIndex, const TQString &profileName ) +{ + if ( !m_context || !m_mainloop || profileName.isEmpty() ) return; + pa_threaded_mainloop_lock( m_mainloop ); + pa_operation *op = pa_context_set_card_profile_by_index( + m_context, cardIndex, profileName.utf8().data(), 0, 0 ); + if ( op ) pa_operation_unref( op ); + pa_threaded_mainloop_unlock( m_mainloop ); +} + #include "pulsemodel.moc" diff --git a/src/model/pulsemodel.h b/src/model/pulsemodel.h index b96f2b1..52e379f 100644 --- a/src/model/pulsemodel.h +++ b/src/model/pulsemodel.h @@ -2,6 +2,8 @@ #include <tqobject.h> #include <tqptrlist.h> +#include <tqmap.h> +#include <tqvaluelist.h> #include <pulse/pulseaudio.h> @@ -9,6 +11,38 @@ class PulseDevice; +// ---- card data structures --------------------------------------------------- + +struct PulseCardProfile { + TQString name; + TQString description; + bool available; +}; + +struct PulseCardPort { + TQString name; + TQString description; + int available; // pa_port_available_t: 0=unknown, 1=no, 2=yes + int direction; // PA_DIRECTION_INPUT=1, PA_DIRECTION_OUTPUT=2 + uint32_t type; // pa_device_port_type_t + TQString availabilityGroup; +}; + +struct PulseCardInfo { + uint32_t index; + TQString name; + TQString description; + TQString activeProfile; + TQString vendor; // device.vendor.name + TQString product; // device.product.name + TQString formFactor; // device.form_factor + TQString busType; // device.bus (pci/usb/etc) + TQValueList<PulseCardProfile> profiles; + TQValueList<PulseCardPort> ports; +}; + +// ---- model ------------------------------------------------------------------ + class PulseModel : public TQObject { TQ_OBJECT @@ -21,6 +55,16 @@ public: void close(); TQPtrList<AudioDevice> devices( AudioDevice::Category cat ) const; + AudioDevice *defaultOutput() const; + AudioDevice *defaultInput() const; + + void setDefaultOutput( AudioDevice *dev ); + void setDefaultInput( AudioDevice *dev ); + void moveSinkInputToSink( AudioDevice *playbackDev, AudioDevice *outputDev ); + + TQValueList<PulseCardInfo> cards() const { return m_cards; } + const PulseCardInfo *card( uint32_t index ) const; + void setCardProfile( uint32_t cardIndex, const TQString &profileName ); protected: void customEvent( TQCustomEvent *e ); @@ -28,14 +72,22 @@ protected: signals: void deviceAdded( AudioDevice *dev ); void deviceRemoved( AudioDevice *dev ); - void ready(); // emitted once initial enumeration is complete + void defaultOutputChanged( AudioDevice *dev ); + void defaultInputChanged( AudioDevice *dev ); + void ready(); + + void cardAdded( uint32_t index ); + void cardRemoved( uint32_t index ); + void cardUpdated( uint32_t index ); private: static void contextStateCb( pa_context *c, void *userdata ); + static void serverInfoCb( pa_context *c, const pa_server_info *info, void *userdata ); static void sinkInfoCb( pa_context *c, const pa_sink_info *info, int eol, void *userdata ); static void sourceInfoCb( pa_context *c, const pa_source_info *info, int eol, void *userdata ); static void sinkInputInfoCb( pa_context *c, const pa_sink_input_info *info, int eol, void *userdata ); static void sourceOutputInfoCb( pa_context *c, const pa_source_output_info *info, int eol, void *userdata ); + static void cardInfoCb( pa_context *c, const pa_card_info *info, int eol, void *userdata ); static void subscribeCb( pa_context *c, pa_subscription_event_type_t t, uint32_t idx, void *userdata ); void enumerateAll(); @@ -48,11 +100,19 @@ private: pa_threaded_mainloop *m_mainloop; pa_context *m_context; - // Keyed by PA index within each category — stable objects, updated in place. TQPtrList<PulseDevice> m_sinks; TQPtrList<PulseDevice> m_sources; TQPtrList<PulseDevice> m_sinkInputs; TQPtrList<PulseDevice> m_sourceOutputs; - PulseDevice *findDevice( TQPtrList<PulseDevice> &list, uint32_t paIndex ); + TQMap<TQString, PulseDevice*> m_sinksByName; + TQMap<TQString, PulseDevice*> m_sourcesByName; + TQString m_defaultSinkName; + TQString m_defaultSourceName; + TQMap<uint32_t, uint32_t> m_sourceOutputToSource; // soIdx → sourceIdx + + TQValueList<PulseCardInfo> m_cards; + + PulseDevice *findDevice( TQPtrList<PulseDevice> &list, uint32_t paIndex ); + PulseCardInfo *findCard( uint32_t index ); }; 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 <tqpainter.h> +#include <tqpixmap.h> +#include <tqimage.h> +#include <tqevent.h> +#include <math.h> + +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 <tqwidget.h> + +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 <tqscrollview.h> +#include <tqlayout.h> +#include <tqlabel.h> +#include <tqcombobox.h> +#include <tqgroupbox.h> +#include <tqstringlist.h> +#include <tqpixmap.h> +#include <tqbitmap.h> +#include <tqpainter.h> +#include <tdelocale.h> +#include <tdeglobal.h> +#include <kiconloader.h> + +#include <pulse/pulseaudio.h> + +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<PulseCardInfo> cards = m_model->cards(); + for ( TQValueList<PulseCardInfo>::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<TQString> names; + int activeIdx = 0, i = 0; + for ( TQValueList<PulseCardProfile>::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<PortWidgets> portWidgetList; + int portIdx = 0; + for ( TQValueList<PulseCardPort>::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<uint32_t, TQComboBox*>::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<TQComboBox*, TQValueList<TQString> >::Iterator nit = m_profileNames.find( combo ); + if ( nit != m_profileNames.end() ) { + TQValueList<TQString> &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<uint32_t, TQValueList<PortWidgets> >::Iterator pit = m_cardPorts.find( index ); + if ( pit == m_cardPorts.end() ) return; + TQValueList<PortWidgets> &portWidgets = pit.data(); + + int i = 0; + for ( TQValueList<PulseCardPort>::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<TQComboBox*>( const_cast<TQObject*>( sender() ) ); + if ( !combo ) return; + + TQMap<TQComboBox*, uint32_t>::Iterator cit = m_comboCard.find( combo ); + if ( cit == m_comboCard.end() ) return; + uint32_t cardIndex = cit.data(); + + TQMap<TQComboBox*, TQValueList<TQString> >::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 <tqwidget.h> +#include <tqmap.h> +#include <tqvaluelist.h> +#include <tqstring.h> + +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<TQComboBox*, uint32_t> m_comboCard; + TQMap<TQComboBox*, TQValueList<TQString> > m_profileNames; + TQMap<uint32_t, TQComboBox*> m_cardCombo; + TQMap<uint32_t, TQValueList<PortWidgets> > 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 <tqlabel.h> #include <tqlayout.h> #include <tqslider.h> -#include <tqtoolbutton.h> +#include <tqlabel.h> +#include <tqframe.h> +#include <tqpainter.h> +#include <tqtooltip.h> +#include <tqevent.h> +#include "balanceknob.h" +#include <tqradiobutton.h> +#include <kled.h> +#include <kiconloader.h> +#include <tdeglobal.h> +#include <kstandarddirs.h> +#include <tdepopupmenu.h> +#include <tdelocale.h> + +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<VerticalLabel*>(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<AudioDevice> sinks = m_model->devices( AudioDevice::Output ); + if ( !sinks.isEmpty() ) { + menu.insertTitle( i18n("Move to Sink") ); + m_moveTargets.clear(); + + PulseDevice *pd = dynamic_cast<PulseDevice*>(m_device); + uint32_t currentSink = pd ? pd->sinkIndex() : PA_INVALID_INDEX; + + int idx = 0; + for ( TQPtrListIterator<AudioDevice> it(sinks); *it; ++it ) { + m_moveTargets.append( *it ); + int itemId = menu.insertItem( (*it)->name(), 1000 + idx ); + PulseDevice *sink = dynamic_cast<PulseDevice*>(*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 <tqwidget.h> #include <tqstring.h> +#include <tqpixmap.h> +#include <tqptrlist.h> 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<AudioDevice> 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 <tqsizepolicy.h> + +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 <tqwidget.h> +#include <kled.h> + +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 <tqpainter.h> +#include <math.h> + +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 <tqwidget.h> + +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 <tqlayout.h> #include <tqscrollview.h> +#include <tqwidgetstack.h> +#include <tqtoolbutton.h> +#include <tqtooltip.h> +#include <tqevent.h> #include <ktabwidget.h> #include <tdelocale.h> +#include <kiconloader.h> +#include <tdeglobal.h> +#include <kstandarddirs.h> +#include <tdeaction.h> +#include <tdeactioncollection.h> +#include <tdemenubar.h> +#include <tdepopupmenu.h> +#include <tdeaboutapplication.h> +#include <tdeaboutdata.h> +#include <tdeapplication.h> +#include "tmixtray.h" +#include "devicespage.h" +#include <tdeconfig.h> +#include <tdemessagebox.h> +#include <kstdguiitem.h> + +// ---- 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<AudioDevice*, TQWidget*>::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<AudioDevice> devs = m_model->devices( cats[i] ); + for ( TQPtrListIterator<AudioDevice> 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 <tqwidget.h> #include <tqmap.h> +#include <tdemainwindow.h> #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 <tqlayout.h> +#include <tqcheckbox.h> +#include <tqspinbox.h> +#include <tqlabel.h> +#include <tqframe.h> +#include <tdeglobal.h> +#include <tdeconfig.h> +#include <tdelocale.h> + +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 <kdialogbase.h> + +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 <kuniqueapplication.h> + +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 <tqlayout.h> +#include <tqframe.h> +#include <tqlabel.h> +#include <tqpushbutton.h> +#include <tqapplication.h> +#include <tqdesktopwidget.h> +#include <tdelocale.h> +#include <tdeconfig.h> +#include <tdeglobal.h> +#include <twin.h> + +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<TQWidget*>( 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 <tqwidget.h> +#include <tqdatetime.h> + +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 <tqevent.h> +#include <tqpixmap.h> +#include <tqstringlist.h> +#include <tqtooltip.h> +#include <tdeglobal.h> +#include <kiconloader.h> +#include <kstandarddirs.h> +#include <tdelocale.h> +#include <tdeconfig.h> +#include <tdepopupmenu.h> + +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<AudioDevice> inputs = model->devices( AudioDevice::Input ); + for ( TQPtrListIterator<AudioDevice> 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<AudioDevice> streams = m_model->devices( AudioDevice::Recording ); + for ( TQPtrListIterator<AudioDevice> 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 <ksystemtray.h> + +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; +}; |
