diff --git a/images/icecast.png b/images/icecast.png new file mode 100644 index 00000000..e57643f2 Binary files /dev/null and b/images/icecast.png differ diff --git a/images/shoutcast.png b/images/shoutcast.png new file mode 100644 index 00000000..083f2caf Binary files /dev/null and b/images/shoutcast.png differ diff --git a/src/alias/Alias.java b/src/alias/Alias.java index 5447cb2c..3770b014 100644 --- a/src/alias/Alias.java +++ b/src/alias/Alias.java @@ -19,7 +19,10 @@ package alias; import java.awt.Color; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; +import java.util.Set; +import java.util.TreeSet; import javax.xml.bind.annotation.XmlAttribute; import javax.xml.bind.annotation.XmlElement; @@ -176,7 +179,34 @@ public class Alias { return !mAliasActions.isEmpty(); } - + + /** + * Perform any validation/cleanup actions on this alias. + */ + public void validate() + { + //Check for duplicate broadcast channels + List broadcastChannels = getBroadcastChannels(); + + if(broadcastChannels.size() > 0) + { + Set channelNames = new TreeSet<>(); + + for(BroadcastChannel broadcastChannel: broadcastChannels) + { + String channelName = broadcastChannel.getChannelName(); + + if(channelName == null || channelName.isEmpty() || channelNames.contains(channelName)) + { + removeAliasID(broadcastChannel); + } + else + { + channelNames.add(channelName); + } + } + } + } /** * Returns the priority level of this alias, if defined, or the default priority */ @@ -320,4 +350,26 @@ public class Alias return broadcastChannels; } + + /** + * Indicates if this alias contains a broadcast channel alias id with the channel name. + */ + public boolean hasBroadcastChannel(String channel) + { + if(channel == null || channel.isEmpty()) + { + return false; + } + + for(AliasID id: getId()) + { + if(id.getType() == AliasIDType.BROADCAST_CHANNEL && + ((BroadcastChannel)id).getChannelName().contentEquals(channel)) + { + return true; + } + } + + return false; + } } diff --git a/src/alias/AliasController.java b/src/alias/AliasController.java index ad8ec80d..0619672c 100644 --- a/src/alias/AliasController.java +++ b/src/alias/AliasController.java @@ -58,7 +58,7 @@ public class AliasController extends JPanel mAliasModel = aliasModel; mAliasEditor = new AliasEditor( mAliasModel, broadcastModel, settingsManager ); - mMultipleAliasEditor = new MultipleAliasEditor( mAliasModel, settingsManager ); + mMultipleAliasEditor = new MultipleAliasEditor( mAliasModel, broadcastModel, settingsManager ); mIconCellRenderer = new IconCellRenderer( settingsManager ); diff --git a/src/alias/AliasModel.java b/src/alias/AliasModel.java index a051a0d0..a46bd532 100644 --- a/src/alias/AliasModel.java +++ b/src/alias/AliasModel.java @@ -26,6 +26,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import javax.swing.table.AbstractTableModel; +import alias.id.broadcast.BroadcastChannel; import sample.Broadcaster; import sample.Listener; import alias.AliasEvent.Event; @@ -210,6 +211,33 @@ public class AliasModel extends AbstractTableModel } } + /** + * Renames any broadcast channels that have the previous name. + * @param previousName to rename + * @param newName to assign to the broadcast channel + */ + public void renameBroadcastChannel(String previousName, String newName) + { + if(previousName == null || previousName.isEmpty() || newName == null || newName.isEmpty()) + { + return; + } + + for(Alias alias: mAliases) + { + if(alias.hasBroadcastChannel(previousName)) + { + for(BroadcastChannel broadcastChannel: alias.getBroadcastChannels()) + { + if(broadcastChannel.getChannelName().contentEquals(previousName)) + { + broadcastChannel.setChannelName(newName); + } + } + } + } + } + @Override public int getRowCount() { @@ -289,6 +317,14 @@ public class AliasModel extends AbstractTableModel public void broadcast( AliasEvent event ) { + Alias alias = event.getAlias(); + + //Validate the alias following a user action that changed the alias or any alias IDs + if(alias != null) + { + alias.validate(); + } + if( event.getEvent() == Event.CHANGE ) { int index = mAliases.indexOf( event.getAlias() ); diff --git a/src/alias/MultipleAliasEditor.java b/src/alias/MultipleAliasEditor.java index e22e0b1b..ba6861d1 100644 --- a/src/alias/MultipleAliasEditor.java +++ b/src/alias/MultipleAliasEditor.java @@ -1,5 +1,8 @@ package alias; +import alias.id.AliasIDType; +import alias.id.broadcast.BroadcastChannel; +import audio.broadcast.BroadcastModel; import gui.editor.Editor; import java.awt.Color; @@ -7,6 +10,7 @@ import java.awt.Dimension; import java.awt.EventQueue; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.util.Arrays; import java.util.List; import javax.swing.DefaultComboBoxModel; @@ -33,385 +37,429 @@ import settings.SettingsManager; import alias.AliasEvent.Event; import alias.id.priority.Priority; -public class MultipleAliasEditor extends Editor> - implements Listener +public class MultipleAliasEditor extends Editor> + implements Listener { - private static final long serialVersionUID = 1L; - private final static Logger mLog = LoggerFactory.getLogger( MultipleAliasEditor.class ); - - private static String HELP_TEXT = - "Select attributes below to change for all selected aliases"; + private static final long serialVersionUID = 1L; + private final static Logger mLog = LoggerFactory.getLogger(MultipleAliasEditor.class); - private JLabel mAliasCountLabel; - private JLabel mAliasCount; + private static String HELP_TEXT = + "Select attributes below to change for all selected aliases"; - private JCheckBox mListCheckBox = new JCheckBox( "List" ); - private JComboBox mListCombo = new JComboBox<>(); - private JCheckBox mGroupCheckBox = new JCheckBox( "Group" ); - private JComboBox mGroupCombo = new JComboBox<>(); - private JCheckBox mIconCheckBox = new JCheckBox( "Icon" ); + private JLabel mAliasCountLabel; + private JLabel mAliasCount; + + private JCheckBox mListCheckBox = new JCheckBox("List"); + private JComboBox mListCombo = new JComboBox<>(); + private JCheckBox mGroupCheckBox = new JCheckBox("Group"); + private JComboBox mGroupCombo = new JComboBox<>(); + private JCheckBox mIconCheckBox = new JCheckBox("Icon"); private JComboBox mMapIconCombo; - private JCheckBox mColorCheckBox = new JCheckBox( "Color" ); + private JCheckBox mColorCheckBox = new JCheckBox("Color"); private JButton mButtonColor; private JButton mBtnIconManager; - private JCheckBox mRecordCheckBox = new JCheckBox( "Record" ); - private JComboBox mRecordActionCombo = new JComboBox<>( Record.values() ); - private JCheckBox mPriorityCheckBox = new JCheckBox( "Priority" ); + private JCheckBox mRecordCheckBox = new JCheckBox("Record"); + private JComboBox mRecordActionCombo = new JComboBox<>(Record.values()); + private JCheckBox mPriorityCheckBox = new JCheckBox("Priority"); private JSlider mPrioritySlider; private JLabel mPrioritySliderLabel; - - private AliasModel mAliasModel; - private SettingsManager mSettingsManager; - - public MultipleAliasEditor( AliasModel aliasModel, SettingsManager settingsManager ) - { - mAliasModel = aliasModel; - mAliasModel.addListener( this ); - - mSettingsManager = settingsManager; - - init(); - } - - public List getAliases() - { - if( hasItem() ) - { - return (List)getItem(); - } - - return null; - } - - public void init() - { - setLayout( new MigLayout( "fill,wrap 2", "[grow,fill][grow,fill]", - "[][][][][][][][][][][][][grow,fill]" ) ); - - mAliasCountLabel = new JLabel( "Alias:" ); - add( mAliasCountLabel ); - - mAliasCount = new JLabel( "Multiple" ); - add( mAliasCount, "span" ); - - add( new JSeparator(), "span" ); - - add( new JLabel( HELP_TEXT ), "span" ); - - add( mListCheckBox ); + private JCheckBox mStreamCheckBox = new JCheckBox("Stream"); + private JComboBox mStreamCombo; - mListCombo.setEditable( true ); - add( mListCombo, "wrap" ); + private AliasModel mAliasModel; + private BroadcastModel mBroadcastModel; + private SettingsManager mSettingsManager; - add( mGroupCheckBox ); + public MultipleAliasEditor(AliasModel aliasModel, BroadcastModel broadcastModel, SettingsManager settingsManager) + { + mAliasModel = aliasModel; + mBroadcastModel = broadcastModel; + mSettingsManager = settingsManager; - mGroupCombo.setEditable( true ); - add( mGroupCombo, "wrap" ); + mAliasModel.addListener(this); - add( mColorCheckBox ); + init(); + } - mButtonColor = new JButton( "Select ..." ); - mButtonColor.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent e ) + public List getAliases() + { + if (hasItem()) + { + return (List) getItem(); + } + + return null; + } + + public void init() + { + setLayout(new MigLayout("fill,wrap 2", "[grow,fill][grow,fill]", + "[][][][][][][][][][][][][][grow,fill]")); + + mAliasCountLabel = new JLabel("Alias:"); + add(mAliasCountLabel); + + mAliasCount = new JLabel("Multiple"); + add(mAliasCount, "span"); + + add(new JSeparator(), "span"); + + add(new JLabel(HELP_TEXT), "span"); + + add(mListCheckBox); + + mListCombo.setEditable(true); + add(mListCombo, "wrap"); + + add(mGroupCheckBox); + + mGroupCombo.setEditable(true); + add(mGroupCombo, "wrap"); + + add(mColorCheckBox); + + mButtonColor = new JButton("Select ..."); + mButtonColor.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) { - Color newColor = JColorChooser.showDialog( - MultipleAliasEditor.this, - "Choose color for this alias", null ); + Color newColor = JColorChooser.showDialog( + MultipleAliasEditor.this, + "Choose color for this alias", null); - if( newColor != null ) - { - mButtonColor.setForeground( newColor ); - mButtonColor.setBackground( newColor ); - } + if (newColor != null) + { + mButtonColor.setForeground(newColor); + mButtonColor.setBackground(newColor); + } } - } ); - add( mButtonColor, "wrap" ); - - add( mIconCheckBox ); - - mMapIconCombo = new JComboBox( mSettingsManager.getMapIcons() ); + }); + add(mButtonColor, "wrap"); - MapIconListCellRenderer renderer = new MapIconListCellRenderer(); - renderer.setPreferredSize( new Dimension( 200, 30 ) ); - mMapIconCombo.setRenderer( renderer ); - add( mMapIconCombo, "wrap" ); + add(mIconCheckBox); - //Dummy place holder - add( new JLabel() ); - - mBtnIconManager = new JButton( "Icon Manager" ); - mBtnIconManager.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent arg0 ) + mMapIconCombo = new JComboBox(mSettingsManager.getMapIcons()); + + MapIconListCellRenderer renderer = new MapIconListCellRenderer(); + renderer.setPreferredSize(new Dimension(200, 30)); + mMapIconCombo.setRenderer(renderer); + add(mMapIconCombo, "wrap"); + + //Dummy place holder + add(new JLabel()); + + mBtnIconManager = new JButton("Icon Manager"); + mBtnIconManager.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent arg0) { - final IconManager iconManager = - new IconManager( mSettingsManager, MultipleAliasEditor.this ); - - EventQueue.invokeLater( new Runnable() - { - @Override - public void run() - { - iconManager.setVisible( true ); - } - } ); + final IconManager iconManager = + new IconManager(mSettingsManager, MultipleAliasEditor.this); + + EventQueue.invokeLater(new Runnable() + { + @Override + public void run() + { + iconManager.setVisible(true); + } + }); } - } ); - - add( mBtnIconManager, "span,wrap" ); - - add( mRecordCheckBox ); - add( mRecordActionCombo ); + }); - //Placeholder - add( new JLabel( " " ) ); - mPrioritySliderLabel = new JLabel( "Priority: " + Priority.MIN_PRIORITY ); - add( mPrioritySliderLabel ); - - add( mPriorityCheckBox ); - - mPrioritySlider = new JSlider( JSlider.HORIZONTAL, - Priority.MIN_PRIORITY, - Priority.MAX_PRIORITY + 1, - Priority.MIN_PRIORITY ); + add(mBtnIconManager, "span,wrap"); - mPrioritySlider.setMajorTickSpacing( 20 ); - mPrioritySlider.setMinorTickSpacing( 5 ); - mPrioritySlider.setPaintTicks( true ); - mPrioritySlider.setLabelTable( mPrioritySlider.createStandardLabels( 25, 25 ) ); - mPrioritySlider.setPaintLabels( true ); - mPrioritySlider.addChangeListener( new ChangeListener() - { - @Override - public void stateChanged( ChangeEvent e ) - { - int priority = mPrioritySlider.getValue(); + add(mRecordCheckBox); + add(mRecordActionCombo); - if( priority > Priority.MAX_PRIORITY ) - { - mPrioritySliderLabel.setText( "Priority: Do Not Monitor" ); - } - else - { - mPrioritySliderLabel.setText( "Priority: " + mPrioritySlider.getValue() ); - } - } - } ); - mPrioritySlider.setToolTipText( HELP_TEXT ); - - add( mPrioritySlider ); - - JButton saveButton = new JButton( "Save" ); - saveButton.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent e ) - { - save(); - } - } ); - add( saveButton ); + //Placeholder + add(new JLabel(" ")); + mPrioritySliderLabel = new JLabel("Audio Priority: " + Priority.MIN_PRIORITY); + add(mPrioritySliderLabel); - JButton resetButton = new JButton( "Reset" ); - resetButton.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent e ) - { - reset(); - } - } ); - add( resetButton ); + add(mPriorityCheckBox); - setModified( false ); - } - - @Override - public void setItem( List item ) - { - super.setItem( item ); + mPrioritySlider = new JSlider(JSlider.HORIZONTAL, + Priority.MIN_PRIORITY, + Priority.MAX_PRIORITY + 1, + Priority.MIN_PRIORITY); - StringBuilder sb = new StringBuilder(); - sb.append( "Multiple Selected [ " ); - - if( hasItem() ) - { - sb.append( String.valueOf( getAliases().size() ) ); - } - else - { - sb.append( "0" ); - } + mPrioritySlider.setMajorTickSpacing(20); + mPrioritySlider.setMinorTickSpacing(5); + mPrioritySlider.setPaintTicks(true); + mPrioritySlider.setLabelTable(mPrioritySlider.createStandardLabels(25, 25)); + mPrioritySlider.setPaintLabels(true); + mPrioritySlider.addChangeListener(new ChangeListener() + { + @Override + public void stateChanged(ChangeEvent e) + { + int priority = mPrioritySlider.getValue(); - sb.append( " ]" ); - - mAliasCount.setText( sb.toString() ); + if (priority > Priority.MAX_PRIORITY) + { + mPrioritySliderLabel.setText("Audio Priority: Do Not Monitor"); + } + else + { + mPrioritySliderLabel.setText("Audio Priority: " + mPrioritySlider.getValue()); + } + } + }); + mPrioritySlider.setToolTipText(HELP_TEXT); - List listNames = mAliasModel.getListNames(); - listNames.add( 0, "" ); - mListCombo.setModel( new DefaultComboBoxModel( - listNames.toArray( new String[ listNames.size() ] ) ) ); - - List groupNames = mAliasModel.getGroupNames(); - groupNames.add( 0, "" ); - mGroupCombo.setModel( new DefaultComboBoxModel( - groupNames.toArray( new String[ groupNames.size() ] ) ) ); + add(mPrioritySlider); - setCheckBoxesUnselected(); - - setModified( false ); - } - - private void setCheckBoxesUnselected() - { - mListCheckBox.setSelected( false ); - mGroupCheckBox.setSelected( false ); - mColorCheckBox.setSelected( false ); - mIconCheckBox.setSelected( false ); - mRecordCheckBox.setSelected( false ); - mPriorityCheckBox.setSelected( false ); - } + add(mStreamCheckBox); - @Override - public void reset() - { - List listNames = mAliasModel.getListNames(); - listNames.add( 0, "" ); - mListCombo.setModel( new DefaultComboBoxModel( - listNames.toArray( new String[ listNames.size() ] ) ) ); - - List groupNames = mAliasModel.getGroupNames(); - groupNames.add( 0, "" ); - mGroupCombo.setModel( new DefaultComboBoxModel( - groupNames.toArray( new String[ groupNames.size() ] ) ) ); - - mButtonColor.setForeground( getForeground() ); - mButtonColor.setBackground( getBackground() ); - - super.reset(); - } - - private boolean hasRequestedChanges() - { - return mListCheckBox.isSelected() || - mGroupCheckBox.isSelected() || - mColorCheckBox.isSelected() || - mIconCheckBox.isSelected() || - mRecordCheckBox.isSelected() || - mPriorityCheckBox.isSelected(); - } + mStreamCombo = new JComboBox(); + mStreamCombo.setEditable(true); + add(mStreamCombo, "wrap"); - @Override - public void save() - { - if( hasRequestedChanges() && hasItem() ) - { - List aliases = getAliases(); - - for( Alias alias: aliases ) - { - if( mListCheckBox.isSelected() ) - { - String list = null; - - if( mListCombo.getSelectedItem() != null ) - { - list = (String)mListCombo.getSelectedItem(); - } - - alias.setList( list ); - } - - if( mGroupCheckBox.isSelected() ) - { - String group = null; - - if( mGroupCombo.getSelectedItem() != null ) - { - group = (String)mGroupCombo.getSelectedItem(); - } - - alias.setGroup( group ); - } - - if( mColorCheckBox.isSelected() ) - { - alias.setColor( mButtonColor.getForeground().getRGB() ); - } - - if( mIconCheckBox.isSelected() ) - { - if( mMapIconCombo.getSelectedItem() != null ) - { - alias.setIconName( ((MapIcon)mMapIconCombo.getSelectedItem()).getName() ); - } - else - { - alias.setIconName( null ); - } - } - - if( mRecordCheckBox.isSelected() ) - { - Record action = - (Record)mRecordActionCombo.getSelectedItem(); + JButton saveButton = new JButton("Save"); + saveButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + save(); + } + }); + add(saveButton); - alias.setRecordable( action == Record.RECORDABLE ); - } - - if( mPriorityCheckBox.isSelected() ) - { - int priority = mPrioritySlider.getValue(); + JButton resetButton = new JButton("Reset"); + resetButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + reset(); + } + }); + add(resetButton); - //This is a work-around -- we use max priority + 1 in the - //gui to indicate do not monitor, and change the value here - if( priority == Priority.MAX_PRIORITY + 1 ) - { - priority = Priority.DO_NOT_MONITOR; - } - - alias.setCallPriority( priority ); - } - } + setModified(false); + } - for( Alias alias: aliases ) - { - //Broadcast an alias change event to save the updates - mAliasModel.broadcast( new AliasEvent( alias, Event.CHANGE ) ); - } - } - } + @Override + public void setItem(List item) + { + super.setItem(item); - @Override - public void receive( AliasEvent event ) - { - if( event.getEvent() == Event.DELETE && - hasItem() && - getAliases().contains( event.getAlias() ) ) - { - getAliases().remove( event.getAlias() ); - setItem( getAliases() ); - } - } - - public enum Record - { - NON_RECORDABLE( "Non-Recordable" ), - RECORDABLE( "Recordable" ); - - private String mLabel; - - private Record( String label ) - { - mLabel = label; - } - - public String toString() - { - return mLabel; - } - } + StringBuilder sb = new StringBuilder(); + sb.append("Multiple Selected [ "); + + if (hasItem()) + { + sb.append(String.valueOf(getAliases().size())); + } + else + { + sb.append("0"); + } + + sb.append(" ]"); + + mAliasCount.setText(sb.toString()); + + List listNames = mAliasModel.getListNames(); + listNames.add(0, ""); + mListCombo.setModel(new DefaultComboBoxModel( + listNames.toArray(new String[listNames.size()]))); + + List groupNames = mAliasModel.getGroupNames(); + groupNames.add(0, ""); + mGroupCombo.setModel(new DefaultComboBoxModel( + groupNames.toArray(new String[groupNames.size()]))); + + List streamNames = mBroadcastModel.getBroadcastConfigurationNames(); + mStreamCombo.setModel(new DefaultComboBoxModel(streamNames.toArray(new String[streamNames.size()]))); + + setCheckBoxesUnselected(); + + setModified(false); + } + + private void setCheckBoxesUnselected() + { + mListCheckBox.setSelected(false); + mGroupCheckBox.setSelected(false); + mColorCheckBox.setSelected(false); + mIconCheckBox.setSelected(false); + mRecordCheckBox.setSelected(false); + mPriorityCheckBox.setSelected(false); + mStreamCheckBox.setSelected(false); + } + + @Override + public void reset() + { + List listNames = mAliasModel.getListNames(); + listNames.add(0, ""); + mListCombo.setModel(new DefaultComboBoxModel( + listNames.toArray(new String[listNames.size()]))); + + List groupNames = mAliasModel.getGroupNames(); + groupNames.add(0, ""); + mGroupCombo.setModel(new DefaultComboBoxModel( + groupNames.toArray(new String[groupNames.size()]))); + + mButtonColor.setForeground(getForeground()); + mButtonColor.setBackground(getBackground()); + + super.reset(); + } + + private boolean hasRequestedChanges() + { + return mListCheckBox.isSelected() || + mGroupCheckBox.isSelected() || + mColorCheckBox.isSelected() || + mIconCheckBox.isSelected() || + mRecordCheckBox.isSelected() || + mPriorityCheckBox.isSelected() || + mStreamCheckBox.isSelected(); + } + + @Override + public void save() + { + if (hasRequestedChanges() && hasItem()) + { + List aliases = getAliases(); + + for (Alias alias : aliases) + { + if (mListCheckBox.isSelected()) + { + String list = null; + + if (mListCombo.getSelectedItem() != null) + { + list = (String) mListCombo.getSelectedItem(); + } + + alias.setList(list); + } + + if (mGroupCheckBox.isSelected()) + { + String group = null; + + if (mGroupCombo.getSelectedItem() != null) + { + group = (String) mGroupCombo.getSelectedItem(); + } + + alias.setGroup(group); + } + + if (mColorCheckBox.isSelected()) + { + alias.setColor(mButtonColor.getForeground().getRGB()); + } + + if (mIconCheckBox.isSelected()) + { + if (mMapIconCombo.getSelectedItem() != null) + { + alias.setIconName(((MapIcon) mMapIconCombo.getSelectedItem()).getName()); + } + else + { + alias.setIconName(null); + } + } + + if (mRecordCheckBox.isSelected()) + { + Record action = + (Record) mRecordActionCombo.getSelectedItem(); + + alias.setRecordable(action == Record.RECORDABLE); + } + + if (mPriorityCheckBox.isSelected()) + { + int priority = mPrioritySlider.getValue(); + + //This is a work-around -- we use max priority + 1 in the + //gui to indicate do not monitor, and change the value here + if (priority == Priority.MAX_PRIORITY + 1) + { + priority = Priority.DO_NOT_MONITOR; + } + + alias.setCallPriority(priority); + } + + if(mStreamCheckBox.isSelected()) + { + if(mStreamCombo.getSelectedItem() != null) + { + String streamName = (String)mStreamCombo.getSelectedItem(); + + boolean hasChannel = false; + + //Ensure the alias doesn't already have the channel + for(BroadcastChannel channel: alias.getBroadcastChannels()) + { + if(channel.getChannelName().equals(streamName)) + { + hasChannel = true; + continue; + } + } + + if(!hasChannel) + { + BroadcastChannel channel = (BroadcastChannel)AliasFactory + .getAliasID(AliasIDType.BROADCAST_CHANNEL); + + channel.setChannelName(streamName); + alias.addAliasID(channel); + } + } + } + } + + for (Alias alias : aliases) + { + //Broadcast an alias change event to save the updates + mAliasModel.broadcast(new AliasEvent(alias, Event.CHANGE)); + } + } + } + + @Override + public void receive(AliasEvent event) + { + if (event.getEvent() == Event.DELETE && + hasItem() && + getAliases().contains(event.getAlias())) + { + getAliases().remove(event.getAlias()); + setItem(getAliases()); + } + } + + public enum Record + { + NON_RECORDABLE("Non-Recordable"), + RECORDABLE("Recordable"); + + private String mLabel; + + private Record(String label) + { + mLabel = label; + } + + public String toString() + { + return mLabel; + } + } } diff --git a/src/audio/broadcast/AudioBroadcaster.java b/src/audio/broadcast/AudioBroadcaster.java index 676f8c4e..aaafdcb9 100644 --- a/src/audio/broadcast/AudioBroadcaster.java +++ b/src/audio/broadcast/AudioBroadcaster.java @@ -30,56 +30,113 @@ import sample.Listener; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.Files; -import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.LinkedTransferQueue; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -public abstract class AudioBroadcaster implements IAudioPacketListener, Listener +public abstract class AudioBroadcaster implements IAudioPacketListener { private final static Logger mLog = LoggerFactory.getLogger( AudioBroadcaster.class ); + public static final int PROCESSOR_RUN_INTERVAL_MS = 1000; private ThreadPoolManager mThreadPoolManager; private ScheduledFuture mScheduledTask; - private StreamManager mStreamManager; private RecordingQueueProcessor mRecordingQueueProcessor = new RecordingQueueProcessor(); - private ArrayBlockingQueue mAudioRecordingQueue; + private LinkedTransferQueue mAudioRecordingQueue = new LinkedTransferQueue<>(); + private byte[] mSilenceFrame; - private int mStreamedAudioCount = 0; private Listener mBroadcastEventListener; private BroadcastState mBroadcastState = BroadcastState.READY; + private StreamManager mStreamManager; + private int mStreamedAudioCount = 0; private BroadcastConfiguration mBroadcastConfiguration; - - private boolean mEnabled = true; + private long mDelay; + private AtomicBoolean mStreaming = new AtomicBoolean(); /** - * AudioBroadcaster for sending audio to a remote broadcastAudio server. + * AudioBroadcaster for streaming audio recordings to a remote streaming audio server. Audio recordings are + * generated by an internal StreamManager that converts an inbound stream of AudioPackets into a recording of the + * desired audio format (e.g. MP3) and nominates the recording to an internal recording queue for streaming. The + * broadcaster supports receiving audio packets from multiple audio sources. Each audio packet's internal audio + * metadata source string is used to reassemble each packet stream. Recordings are capped at 30 seconds length. + * If a source audio packet stream exceeds 30 seconds in length, it will be chunked into 30 second recordings. + * + * This broadcaster supports a time delay setting for delaying broadcast of audio recordings. The delay setting is + * defined in the broadcast configuration. When this delay is greater than zero, the recording will remain in the + * audio broadcaster queue until the recording start time + delay elapses. Audio recordings are processed in a FIFO + * manner. + * + * Use the start() and stop() methods to connect to/disconnect from the remote server. Audio recordings will be + * streamed to the remote server when available. One second silence frames will be broadcast to the server when + * there are no recordings available, in order to maintain a connection with the remote server. Any audio packet + * streams received while the broadcaster is stopped will be ignored. + * + * The last audio packet's metadata is automatically attached to the closed audio recording when it is enqueued for + * broadcast. That metadata will be updated on the remote server once the audio recording is opened for streaming. */ public AudioBroadcaster(ThreadPoolManager threadPoolManager, BroadcastConfiguration broadcastConfiguration) { mThreadPoolManager = threadPoolManager; mBroadcastConfiguration = broadcastConfiguration; - - //Recording queue - bounded at 10 queued recordings maximum - new stream recordings will be deleted once full - mAudioRecordingQueue = new ArrayBlockingQueue<>(10); + mDelay = mBroadcastConfiguration.getDelay(); //Create a 1 second silence frame - from 1200 millis of silence mSilenceFrame = BroadcastFactory.getSilenceFrame(getBroadcastConfiguration().getBroadcastFormat(), 1200); - mStreamManager = new StreamManager(threadPoolManager, this, broadcastConfiguration.getDelay(), + mStreamManager = new StreamManager(threadPoolManager, this, SystemProperties.getInstance().getApplicationFolder(BroadcastModel.TEMPORARY_STREAM_DIRECTORY)); - - mStreamManager.start(); - - startProcessor(); } /** - * Recording backlog awaiting streaming + * Connects to the remote server specified by the broadcast configuration and starts audio streaming. + */ + public void start() + { + if(mStreaming.compareAndSet(false, true)) + { + mStreamManager.start(); + + if(mScheduledTask == null) + { + if(mThreadPoolManager != null) + { + mScheduledTask = mThreadPoolManager.scheduleFixedRate(ThreadPoolManager.ThreadType.AUDIO_PROCESSING, + mRecordingQueueProcessor, PROCESSOR_RUN_INTERVAL_MS, TimeUnit.MILLISECONDS ); + } + } + } + } + + /** + * Disconnects from the remote server. + */ + public void stop() + { + if(mStreaming.compareAndSet(true, false)) + { + mStreamManager.stop(); + + if(mThreadPoolManager != null && mScheduledTask != null) + { + mThreadPoolManager.cancel(mScheduledTask); + } + + disconnect(); + } + } + + /** + * Disconnects the broadcaster from the remote server for a reset or final stop. + */ + protected abstract void disconnect(); + + /** + * Size of recording queue for recordings awaiting streaming */ public int getQueueSize() { @@ -95,51 +152,18 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener } /** - * Queues the streamable audio recording for broadcastAudio. The bounded queue is managed by removing older recordings - * at the head of the queue, once full, to make room for new recordings. This ensures that once the broadcaster - * (re)starts broadcasting or catches up with the queue, that it is broadcasting the most current audio recordings. + * Primary insert method for the stream manager to nominate completed audio recordings for broadcast. * * @param recording to queue for broadcasting */ public void receive(StreamableAudioRecording recording) { - boolean success = false; - - try - { - success = mAudioRecordingQueue.add(recording); - } - catch(IllegalStateException ise) - { - //The queue is full - delete the oldest recording at the head of the queue to make room for a new one - removeRecording(mAudioRecordingQueue.poll()); - } - - if(!success) - { - try - { - success = mAudioRecordingQueue.add(recording); - } - catch(IllegalStateException ise) - { - //Do nothing on second attempt - } - } - - //If we can't queue the recording after two attempts, remove the recording - if(!success) - { - removeRecording(recording); - } - else - { - broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_QUEUE_CHANGE)); - } + mAudioRecordingQueue.add(recording); + broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_QUEUE_CHANGE)); } /** - * Removes the temporary recording file from disk. + * Cleanup method to remove a temporary recording file from disk. * * @param recording to remove */ @@ -164,16 +188,16 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener } /** - * IAudioPacketListener interface method + * IAudioPacketListener interface method to gain access to the internal stream manager for enqueuing audio packets. */ @Override public Listener getAudioPacketListener() { - return this; + return mStreamManager; } /** - * Broadcasts the audio binary data. + * Broadcast binary audio data frames or sequences. */ protected abstract void broadcastAudio(byte[] audio); @@ -183,73 +207,6 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener */ protected abstract void broadcastMetadata(AudioMetadata metadata); - /** - * Disconnects the broadcaster and halts all broadcasting. - */ - public void stop() - { - mEnabled = false; - mStreamManager.stop(); - stopProcessor(); - } - - /** - * Primary audio packet input method. Audio packets are queued and then processed by the underlying broadcastAudio - * handler for broadcastAudio to the remote server. - */ - public void receive(AudioPacket packet) - { - if(mEnabled) - { - mStreamManager.receive(packet); - } - } - - /** - * Starts the audio queue processor. The audio packet queue is serviced every 100 milliseconds. Subsequent calls - * to this method once running are ignored. - */ - private void startProcessor() - { - if(mEnabled && mScheduledTask == null) - { - if(mThreadPoolManager != null) - { - mScheduledTask = mThreadPoolManager.scheduleFixedRate(ThreadPoolManager.ThreadType.AUDIO_PROCESSING, - mRecordingQueueProcessor, PROCESSOR_RUN_INTERVAL_MS, TimeUnit.MILLISECONDS ); - } - } - } - - /** - * Stops the audio queue processor - */ - private void stopProcessor() - { - if(mThreadPoolManager != null && mScheduledTask != null) - { - mThreadPoolManager.cancel(mScheduledTask); - } - } - - /** - * Pauses or unpauses the broadcastAudio - */ - public void setPaused(boolean paused) - { - if(!getBroadcastState().isErrorState()) - { - if(paused) - { - setBroadcastState(BroadcastState.PAUSED); - } - else - { - setBroadcastState(BroadcastState.READY); - } - } - } - /** * Registers the listener to receive broadcastAudio state changes */ @@ -288,6 +245,11 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener mBroadcastState = state; broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_STATE_CHANGE)); + + if(mBroadcastState.isErrorState()) + { + stop(); + } } } @@ -299,7 +261,10 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener return mBroadcastState; } - public boolean connected() + /** + * Indicates if the broadcaster is currently connected to the remote server + */ + protected boolean connected() { return getBroadcastState() == BroadcastState.CONNECTED; } @@ -314,6 +279,15 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener return state != BroadcastState.CONNECTED && !state.isErrorState(); } + /** + * Indicates if the current broadcast state is an error state, meaning that it cannot recover or connect using the + * current configuration. + */ + protected boolean isErrorState() + { + return getBroadcastState().isErrorState(); + } + /** * Audio recording queue processor. Fetches recordings from the queue and chunks the recording byte content @@ -366,19 +340,24 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener */ private void nextRecording() { + boolean metadataUpdateRequired = false; + if(mInputStream != null) { mStreamedAudioCount++; broadcast(new BroadcastEvent(AudioBroadcaster.this, BroadcastEvent.Event.BROADCASTER_STREAMED_COUNT_CHANGE)); + metadataUpdateRequired = true; } mInputStream = null; - StreamableAudioRecording recording = mAudioRecordingQueue.poll(); + StreamableAudioRecording nextRecording = mAudioRecordingQueue.peek(); - if(recording != null) + if(nextRecording != null && nextRecording.getStartTime() + mDelay <= System.currentTimeMillis()) { + StreamableAudioRecording recording = mAudioRecordingQueue.poll(); + try { byte[] audio = Files.readAllBytes(recording.getPath()); @@ -398,10 +377,7 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener mChunkSize = (int)(mInputStream.available() / wholeIntervalChunks) + 1; broadcastMetadata(recording.getAudioMetadata()); - } - else - { - broadcastMetadata(null); + metadataUpdateRequired = false; } } catch(IOException ioe) @@ -416,7 +392,9 @@ public abstract class AudioBroadcaster implements IAudioPacketListener, Listener broadcast(new BroadcastEvent(AudioBroadcaster.this, BroadcastEvent.Event.BROADCASTER_QUEUE_CHANGE)); } - else + + //If we closed out a recording and don't have anything new, send an empty metadata update + if(metadataUpdateRequired) { broadcastMetadata(null); } diff --git a/src/audio/broadcast/BroadcastConfigurationEditor.java b/src/audio/broadcast/BroadcastConfigurationEditor.java new file mode 100644 index 00000000..678ba383 --- /dev/null +++ b/src/audio/broadcast/BroadcastConfigurationEditor.java @@ -0,0 +1,128 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import alias.AliasModel; +import audio.broadcast.broadcastify.BroadcastifyConfiguration; +import audio.broadcast.broadcastify.BroadcastifyConfigurationEditor; +import gui.editor.DocumentListenerEditor; +import settings.SettingsManager; + +import javax.swing.*; + +public abstract class BroadcastConfigurationEditor extends DocumentListenerEditor +{ + protected SettingsManager mSettingsManager; + protected BroadcastModel mBroadcastModel; + protected AliasModel mAliasModel; + + public BroadcastConfigurationEditor(BroadcastModel broadcastModel, AliasModel aliasModel, SettingsManager settingsManager) + { + mBroadcastModel = broadcastModel; + mAliasModel = aliasModel; + mSettingsManager = settingsManager; + } + + /** + * Updates the configuration with the new name and prompts the user to update any aliases that have a broadcast + * channel alias id with the old name to update to the new name. + * + * @param broadcastConfiguration to assign a new channel name + * @param newName to assign to the broadcast configuration. Note: assumes that validateConfiguration() has been + * invoked to verify that newName is non-null and non-empty + */ + protected void updateConfigurationName(BroadcastConfiguration broadcastConfiguration, String newName) + { + String previousName = broadcastConfiguration.getName(); + + if(previousName == null || previousName.isEmpty()) + { + broadcastConfiguration.setName(newName); + return; + } + + if(!previousName.equals(newName)) + { + mAliasModel.renameBroadcastChannel(previousName, newName); + broadcastConfiguration.setName(newName); + } + } + + /** + * Validates a text field control for a non-null, non-empty value + * @param field to validate + * @param title to use for error dialog + * @param message to use for error dialog + * @return true if field contains a non-null, non-empty value + */ + protected boolean validateTextField(JTextField field, String title, String message) + { + String text = field.getText(); + + if(text == null || text.isEmpty()) + { + JOptionPane.showMessageDialog(BroadcastConfigurationEditor.this, message, title, + JOptionPane.ERROR_MESSAGE); + + field.requestFocus(); + return false; + } + + return true; + } + + /** + * Validates the text field control that contains an integer value for non-null, non-empty and within the + * specified min/max valid range. + * @param field to validate + * @param title to use for error dialog + * @param message to use for error dialog + * @param minValid value + * @param maxValid value + * @return true if field contains a non-null, non-empty value within the valid min/max range + */ + protected boolean validateIntegerTextField(JTextField field, String title, String message, int minValid, int maxValid) + { + if(validateTextField(field, title, message)) + { + String text = field.getText(); + + try + { + int value = Integer.parseInt(text); + + if(minValid <= value && value <= maxValid) + { + return true; + } + } + catch(Exception e) + { + //Do nothing, we couldn't parse the number value + } + + JOptionPane.showMessageDialog(BroadcastConfigurationEditor.this, message, title, + JOptionPane.ERROR_MESSAGE); + + field.requestFocus(); + } + + return false; + } +} diff --git a/src/audio/broadcast/BroadcastFactory.java b/src/audio/broadcast/BroadcastFactory.java index 740c4574..8feca393 100644 --- a/src/audio/broadcast/BroadcastFactory.java +++ b/src/audio/broadcast/BroadcastFactory.java @@ -18,6 +18,7 @@ ******************************************************************************/ package audio.broadcast; +import alias.AliasModel; import audio.AudioPacket; import audio.broadcast.broadcastify.BroadcastifyConfiguration; import audio.broadcast.broadcastify.BroadcastifyConfigurationEditor; @@ -25,6 +26,7 @@ import audio.broadcast.icecast.IcecastHTTPAudioBroadcaster; import audio.broadcast.icecast.IcecastHTTPConfiguration; import audio.broadcast.icecast.IcecastTCPAudioBroadcaster; import audio.broadcast.icecast.IcecastTCPConfiguration; +import audio.broadcast.icecast.IcecastTCPConfigurationEditor; import audio.broadcast.shoutcast.v1.ShoutcastV1Configuration; import audio.broadcast.shoutcast.v1.ShoutcastV1AudioBroadcaster; import audio.broadcast.shoutcast.v2.ShoutcastV2Configuration; @@ -150,6 +152,7 @@ public class BroadcastFactory */ public static Editor getEditor(BroadcastConfiguration configuration, BroadcastModel broadcastModel, + AliasModel aliasModel, SettingsManager settingsManager) { Editor editor; @@ -157,7 +160,10 @@ public class BroadcastFactory switch (configuration.getBroadcastServerType()) { case BROADCASTIFY: - editor = new BroadcastifyConfigurationEditor(broadcastModel, settingsManager); + editor = new BroadcastifyConfigurationEditor(broadcastModel, aliasModel, settingsManager); + break; + case ICECAST_TCP: + editor = new IcecastTCPConfigurationEditor(broadcastModel, aliasModel, settingsManager); break; default: editor = new EmptyEditor(); diff --git a/src/audio/broadcast/BroadcastModel.java b/src/audio/broadcast/BroadcastModel.java index 5f65d208..165cf503 100644 --- a/src/audio/broadcast/BroadcastModel.java +++ b/src/audio/broadcast/BroadcastModel.java @@ -40,6 +40,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -228,7 +229,9 @@ public class BroadcastModel extends AbstractTableModel implements Listener