diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..185f286 --- /dev/null +++ b/.classpath @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50c2ba1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +Test Data/ \ No newline at end of file diff --git a/.project b/.project new file mode 100644 index 0000000..6976b44 --- /dev/null +++ b/.project @@ -0,0 +1,17 @@ + + + PlaylistEditor + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b6593d2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "Debug (Launch)-PlaylistWindow", + "request": "launch", + "mainClass": "com.pe.PlaylistWindow", + "projectName": "PlaylistEditor" + } + ] +} diff --git a/External JARs/MP3agic.jar b/External JARs/MP3agic.jar new file mode 100644 index 0000000..2c91b89 Binary files /dev/null and b/External JARs/MP3agic.jar differ diff --git a/External JARs/jlayer-1.0.1.jar b/External JARs/jlayer-1.0.1.jar new file mode 100644 index 0000000..b4870bc Binary files /dev/null and b/External JARs/jlayer-1.0.1.jar differ diff --git a/defaults.conf b/defaults.conf new file mode 100644 index 0000000..75e199f --- /dev/null +++ b/defaults.conf @@ -0,0 +1 @@ +C:\Workspaces\java-playlist-editor\Test DataH:\Documents\Other\Eclipse Workspace\PlaylistEditor\Test Data diff --git a/src/com/pe/Playlist.java b/src/com/pe/Playlist.java new file mode 100644 index 0000000..6878dda --- /dev/null +++ b/src/com/pe/Playlist.java @@ -0,0 +1,393 @@ +package com.pe; + +import java.util.ArrayList; +import java.util.Collections; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Files; +import java.io.PrintWriter; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * Represents a playlist. + * + * @author olly.rowe + */ +public class Playlist +{ + // The playlist attributes + private String name; + private String author; + private ArrayList songs; + + // The file to where the playlist is stored + private File file; + + // Parser constants + public static final String WPL = "wpl"; + public static final String M3U = "m3u"; + private static final String WPL_FILE_PREFIX = ""; + + /** + * Class Constructor for new playlist. + */ + public Playlist() + { + this.name = new String(); + this.author = new String(); + this.songs = new ArrayList(); + } + + /** + * Alternative Class Constructor for existing files. + * + * @param File - The file containing the playlist data. + */ + public Playlist(File file) throws FileNotFoundException + { + // Set the file + this.file = file; + + // Initialise the songs array + this.songs = new ArrayList(); + + // Check that the correct file type as been passed + if (file.getName().endsWith(".wpl")) + { + // Try to read the data from the file + try + { + DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder docBuilder = docFactory.newDocumentBuilder(); + Document doc = docBuilder.parse(file); + Node titleNode = doc.getElementsByTagName("title").item(0); + + this.name = titleNode.getTextContent(); + + Node authorNode = doc.getElementsByTagName("author").item(0); + + this.author = authorNode.getTextContent(); + + NodeList mediaNodes = doc.getElementsByTagName("media"); + + for (int i = 0; i < mediaNodes.getLength(); i++) + { + Song song = new Song(mediaNodes.item(i).getAttributes().getNamedItem("src").getTextContent()); + + this.songs.add(song); + } + } + catch (FileNotFoundException e) + { + throw new FileNotFoundException(); + } + catch (ParserConfigurationException | SAXException | IOException e) + { + e.printStackTrace(); + } + } + else + { + System.err.println("Playlist initialisation error. Unsupportted file format detected."); + } + } + + /** + * Calls the corresponding parser and writes the output to the file + */ + public void save(String fileFormat) + { + // Initialise output string + String output = ""; + + // Choose the correct parser based on the passed file format value + switch(fileFormat) + { + case WPL: + { + output = this.parseToWPL(); + break; + } + case M3U: + { + break; + } + default: + { + System.err.println("Failed attempt to save playist. Unsupported file format: " + fileFormat); + return; + } + } + + // Write the output to the file + try + { + PrintWriter pw = new PrintWriter(this.getFile().getAbsolutePath()); + pw.println(output); + pw.close(); + } + catch (FileNotFoundException e) + { + e.printStackTrace(); + } + } + + /** + * Parser for WPL file format. + */ + public String parseToWPL() + { + // Initialise the output string + String output = WPL_FILE_PREFIX; + + // Attempt to parse playlist + try + { + // Adapted from: https://examples.javacodegeeks.com/core-java/xml/parsers/documentbuilderfactory/create-xml-file-in-java-using-dom-parser-example/ + DocumentBuilderFactory documentFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder documentBuilder = documentFactory.newDocumentBuilder(); + Document document = documentBuilder.newDocument(); + + Element smil = document.createElement("smil"); + document.appendChild(smil); + + Element head = document.createElement("head"); + + smil.appendChild(head); + + Element title = document.createElement("title"); + + title.appendChild(document.createTextNode(this.getName())); + + head.appendChild(title); + + Element author = document.createElement("author"); + + author.appendChild(document.createTextNode(this.getAuthor())); + + head.appendChild(author); + + Element body = document.createElement("body"); + + smil.appendChild(body); + + Element seq = document.createElement("seq"); + + body.appendChild(seq); + + for (Song song : this.songs) + { + // Create a new media tag for the song + Element media = document.createElement("media"); + + // Set the src attribute to the absolute path of its corresponding file location + media.setAttribute("src", song.getFile().getAbsolutePath()); + // Set the albumTitle attribute + media.setAttribute("albumTitle", song.getAlbum()); + // Set the albumArtist attribute + media.setAttribute("albumArtist", song.getArtist()); + // Set the trackTitle attribute + media.setAttribute("trackTitle", song.getTitle()); + // Set the trackArtist attribute + media.setAttribute("trackArtist", song.getArtist()); + // Set the duration attribute + media.setAttribute("duration", Long.toString(song.getLength())); + + // Add the media tag to the seq tag + seq.appendChild(media); + } + + // Create new transformation objects + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + DOMSource domSource = new DOMSource(document); + // Create a new StringWriter object to output the result to + StringWriter outWriter = new StringWriter(); + // The stream result, outputting to the String Writer + StreamResult streamResult = new StreamResult(outWriter); + // Transform the DOM Object to an XML File + transformer.transform(domSource, streamResult); + StringBuffer sb = outWriter.getBuffer(); + // Output result to the output string + output += sb.toString(); + } + catch (ParserConfigurationException | TransformerException e) + { + e.printStackTrace(); + } + return output; + } + + /** + * Identifies whether this playlist is currently saved. + * + * Also returns true in the special case where the playlist details are completely blank. + * + * @return - Whether the playlist is saved. + */ + public boolean isSaved() + { + // Check whether the detials are blank + if (this.file == null && this.author.equals("") && this.name.equals("") && this.songs.size() == 0) + { + // If all details are blank then there is no need to save, therefore return true in this case + return true; + } + + // Check if this playlist has a file associated with it yet + if (this.file == null) + { + // If there is not an associated file then it has not yet been saved + return false; + } + // Parse the current state of this playlist + String parserOutput = this.parseToWPL(); + + // Read the corresponding file + String fileOutput = new String(); + try + { + fileOutput = Files.readAllLines(this.file.toPath()).get(0); + } + catch (IOException e) + { + e.printStackTrace(); + } + + // Compare the file's contents with the parser output and then return whether they match or not + if (parserOutput.equals(fileOutput)) + { + return true; + } + return false; + } + + public String getName() + { + return this.name; + } + + public void setName(String name) + { + this.name = name; + } + + public String getAuthor() + { + return this.author; + } + + public void setAuthor(String author) + { + this.author = author; + } + + public ArrayList getSongs() + { + return this.songs; + } + + public Song getSong(int index) + { + // Check that the index is valid + if (index <= (this.songs.size() - 1) && index >= 0) + { + return this.songs.get(index); + } + return null; + } + + public int getIndexOf(Song song) + { + return this.songs.indexOf(song); + } + + public Song getLastSong() + { + // Check that the songs array isn't empty + if (this.songs.size() != 0) + { + return this.songs.get(this.songs.size() - 1); + } + return null; + } + + public void addSong(Song song) + { + this.songs.add(song); + } + + public void removeSong(int songIndex) + { + this.songs.remove(songIndex); + } + + /** + * Swaps the order of two songs on the playlist. + * + * @param index1 - The index of the first song to be swapped. + * @param index2 - The index of the seconds song to be swapped. + */ + public void swapSongs(int index1, int index2) + { + Collections.swap(this.songs, index1, index2); + } + + public File getFile() + { + return this.file; + } + + public void setFile(File file) + { + this.file = file; + } + + /** + * Returns the size of the songs array. + */ + public int getSize() + { + return this.songs.size(); + } + + /** + * Calculates the total play time of all of the songs on this playlist. + * + * @return - A String value representing the total play time in hours, minutes and seconds. + */ + public String getPlayTime() + { + long playTime = 0; + + for (Song song : this.songs) + { + playTime += song.getLength(); + } + + int hours = (int) playTime / 3600; + int remainder = (int) playTime - hours * 3600; + int mins = remainder / 60; + remainder = remainder - mins * 60; + int secs = remainder; + + return (((hours == 0) ? "" : hours + "h ") + ((hours == 0 && mins == 0) ? "" : mins + "m ") + secs + "s"); + } +} diff --git a/src/com/pe/PlaylistWindow.java b/src/com/pe/PlaylistWindow.java new file mode 100644 index 0000000..aeb6b77 --- /dev/null +++ b/src/com/pe/PlaylistWindow.java @@ -0,0 +1,895 @@ +package com.pe; + +import com.pe.Song; +import com.pe.Playlist; +import com.pe.UserDefaults; +import com.pe.utils.FileTree; +import com.pe.utils.FileNode; +import com.pe.audio.MusicPlayer; +import java.awt.EventQueue; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.filechooser.FileNameExtensionFilter; +import javax.swing.table.DefaultTableModel; +import javax.swing.tree.TreePath; +import javax.swing.JTable; +import javax.swing.UIManager; +import javax.swing.UnsupportedLookAndFeelException; +import java.awt.Color; +import java.awt.Dimension; +import javax.swing.JButton; +import javax.swing.JFileChooser; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.awt.event.ActionEvent; +import javax.swing.JTextField; +import javax.swing.JTextPane; +import javax.swing.JLabel; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import java.awt.Toolkit; +import javax.swing.JMenuBar; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JSeparator; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.Image; +import javax.swing.ImageIcon; + +/** + * The main GUI for the playlist editor. + * + * @author olly.rowe + */ +@SuppressWarnings("serial") +public class PlaylistWindow extends JFrame implements ActionListener { + // The current version number + private final String VERSION = "1.0.0"; + + // The user defaults object + private UserDefaults userDefaults; + + // The playlist being edited + private Playlist playlist; + + // The song table and table model + private JTable songTable; + private DefaultTableModel songTableModel; + + // The playlist details text fields + private JTextField playlistNameTextField; + private JTextField playlistAuthorTextField; + private JTextField playlistSizeTextField; + private JTextField playlistPlayTimeTextField; + + // The menu bar components + private JMenuItem mntmNew; + private JMenuItem mntmOpen; + private JMenuItem mntmSave; + private JMenuItem mntmExit; + private JMenuItem mntmSplitTrack; + private JMenuItem mntmReleaseNotes; + + // The button components + private JButton changeDirectoryButton; + private JButton addSongButton; + private JButton removeSongButton; + private JButton saveButton; + private JButton exitButton; + private JButton upArrowButton; + private JButton downArrowButton; + + // The file explorer tree + private FileTree fileTree; + + // The music player component + private MusicPlayer musicPlayer; + private JPanel albumArtPanel; + + /** + * Launch the application. + */ + public static void main(String[] args) { + EventQueue.invokeLater(new Runnable() { + public void run() { + try { + PlaylistWindow frame = new PlaylistWindow(); + frame.setVisible(true); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } + + /** + * Class Constructor. Generates the GUI. + */ + public PlaylistWindow() { + // Create the playlist object + this.playlist = new Playlist(); + + // Initialise the user defaults + this.userDefaults = new UserDefaults(); + + // Set the window title + setTitle("Playlist Editor " + VERSION); + // Set the window icon + setIconImage(Toolkit.getDefaultToolkit().getImage(getClass().getResource("/images/icon.png"))); + // Prevent the window from being resized + setResizable(false); + + // Set the GUI appearance to match that of the users operating system + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + UIManager.put("Slider.focus", UIManager.get("Slider.background")); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException + | UnsupportedLookAndFeelException e) { + System.err.println("An error occured setting the UIManager look and feel."); + } + + // Set the window dimensions + setBounds(100, 100, 800, 540); + + // Set default closing operation to nothing + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + // Add custom closing operation + addWindowListener(new WindowAdapter() { + public void windowClosing(WindowEvent evt) { + exit(); + } + }); + + // Create the menu bar + JMenuBar menuBar = new JMenuBar(); + setJMenuBar(menuBar); + // Add menu components to the menu bar + JMenu mnFile = new JMenu("File"); + menuBar.add(mnFile); + mntmNew = new JMenuItem("Create New Playlist..."); + mntmNew.addActionListener(this); + mnFile.add(mntmNew); + mntmOpen = new JMenuItem("Open Existing Playlist..."); + mntmOpen.addActionListener(this); + mnFile.add(mntmOpen); + mntmSave = new JMenuItem("Save"); + mntmSave.addActionListener(this); + mnFile.add(mntmSave); + JSeparator separator = new JSeparator(); + mnFile.add(separator); + mntmExit = new JMenuItem("Exit"); + mntmExit.addActionListener(this); + mnFile.add(mntmExit); + JMenu mnTools = new JMenu("Tools"); + menuBar.add(mnTools); + mntmSplitTrack = new JMenuItem("Split Track"); + mntmSplitTrack.addActionListener(this); + mnTools.add(mntmSplitTrack); + JMenu mnAbout = new JMenu("About"); + menuBar.add(mnAbout); + mntmReleaseNotes = new JMenuItem("Release Notes"); + mntmReleaseNotes.addActionListener(this); + mnAbout.add(mntmReleaseNotes); + + // Create the main content pane + JPanel contentPane = new JPanel(); + contentPane.setBorder(new EmptyBorder(5, 5, 5, 5)); + setContentPane(contentPane); + contentPane.setLayout(null); + + // Create the track list section + JPanel trackListPanel = new JPanel(); + trackListPanel.setBorder(new TitledBorder(UIManager.getBorder("TitledBorder.border"), "Track List", + TitledBorder.LEADING, TitledBorder.TOP, null, new Color(0, 0, 0))); + trackListPanel.setBounds(338, 87, 446, 262); + contentPane.add(trackListPanel); + trackListPanel.setLayout(null); + + // Create the song table and table model + songTable = new JTable(); + songTableModel = new DefaultTableModel(new Object[][] {}, new String[] { "Track No.", "Artist", "Title" }) { + // Disable cell-editing + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + // Set the table model + songTable.setModel(songTableModel); + // Set table attributes + songTable.getColumnModel().getColumn(0).setPreferredWidth(56); + songTable.getColumnModel().getColumn(0).setMaxWidth(56); + songTable.setColumnSelectionAllowed(false); + songTable.setFillsViewportHeight(true); + songTable.setRowSelectionAllowed(true); + + // Create a new scroll pane and assign the songs table to it + JScrollPane songTableScrollPane = new JScrollPane(songTable); + songTableScrollPane.setBounds(10, 21, 426, 196); + songTableScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + songTableScrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); + trackListPanel.add(songTableScrollPane); + + // Create remove song button + removeSongButton = new JButton("Remove"); + removeSongButton.setBounds(347, 228, 89, 23); + removeSongButton.addActionListener(this); + trackListPanel.add(removeSongButton); + + Image upArrowIcon = (new ImageIcon(getClass().getResource("/images/arrow up.png"))).getImage() + .getScaledInstance(10, 10, Image.SCALE_SMOOTH); + upArrowButton = new JButton(new ImageIcon(upArrowIcon)); + upArrowButton.addActionListener(this); + upArrowButton.setBounds(10, 228, 23, 23); + trackListPanel.add(upArrowButton); + + Image downArrowIcon = (new ImageIcon(getClass().getResource("/images/arrow down.png"))).getImage() + .getScaledInstance(10, 10, Image.SCALE_SMOOTH); + downArrowButton = new JButton(new ImageIcon(downArrowIcon)); + downArrowButton.addActionListener(this); + downArrowButton.setBounds(37, 228, 23, 23); + trackListPanel.add(downArrowButton); + + // Create browse for music panel + JPanel browseForMusicPanel = new JPanel(); + browseForMusicPanel.setBorder(new TitledBorder(UIManager.getBorder("TitledBorder.border"), "Browse for Music", + TitledBorder.LEADING, TitledBorder.TOP, null, new Color(0, 0, 0))); + browseForMusicPanel.setBounds(10, 11, 318, 469); + contentPane.add(browseForMusicPanel); + browseForMusicPanel.setLayout(null); + + // Create add song to playlist button + addSongButton = new JButton("Add"); + addSongButton.setBounds(190, 435, 118, 23); + addSongButton.addActionListener(this); + browseForMusicPanel.add(addSongButton); + + // Change the default leaf icon to music note. As the tree will filter mp3 + // files, only mp3 files will display this icon + UIManager.put("Tree.leafIcon", new ImageIcon((new ImageIcon(getClass().getResource("/images/music note.png"))) + .getImage().getScaledInstance(14, 14, Image.SCALE_SMOOTH))); + + // Create the file explorer tree using the default directory if one exists, + // otherwise use the user's C: drive. Filter it to show only mp3 files + fileTree = new FileTree( + (this.userDefaults.getDefaultBrowserDir() == null) ? "C:\\" : this.userDefaults.getDefaultBrowserDir(), + Arrays.asList(".mp3")); + + // Add mouse listener to detect double clicks + fileTree.addMouseListener(new MouseAdapter() { + public void mousePressed(MouseEvent e) { + if (fileTree.getRowForLocation(e.getX(), e.getY()) != -1) { + FileNode selectedNode = (FileNode) fileTree.getPathForLocation(e.getX(), e.getY()) + .getLastPathComponent(); + + // If a double-click is detected, add the song to the current playlist + if (e.getClickCount() == 2) { + addSong(selectedNode); + } + } + } + }); + + // Create a scroll pane and add the file tree to it + JScrollPane fileTreeScrollPane = new JScrollPane(fileTree); + fileTree.setLayout(new FlowLayout(FlowLayout.CENTER, 5, 5)); + fileTreeScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS); + fileTreeScrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); + fileTreeScrollPane.setBounds(10, 22, 298, 402); + browseForMusicPanel.add(fileTreeScrollPane); + + // Create change directory button which changes contents of file tree + changeDirectoryButton = new JButton("..."); + changeDirectoryButton.setBounds(10, 435, 42, 23); + browseForMusicPanel.add(changeDirectoryButton); + changeDirectoryButton.addActionListener(this); + + // Create new playlist details panel + JPanel playlistDetailsPanel = new JPanel(); + playlistDetailsPanel.setBorder(new TitledBorder(UIManager.getBorder("TitledBorder.border"), "Details", + TitledBorder.LEADING, TitledBorder.TOP, null, new Color(0, 0, 0))); + playlistDetailsPanel.setBounds(338, 11, 446, 65); + contentPane.add(playlistDetailsPanel); + playlistDetailsPanel.setLayout(null); + + // Create playlist name label and text field + JLabel lblName = new JLabel("Name"); + lblName.setBounds(10, 18, 46, 14); + playlistDetailsPanel.add(lblName); + playlistNameTextField = new JTextField(); + playlistNameTextField.setBounds(54, 14, 241, 20); + playlistDetailsPanel.add(playlistNameTextField); + playlistNameTextField.setColumns(10); + addChangeListener(playlistNameTextField); + + // Create playlist author label and text field + JLabel lblAuthor = new JLabel("Author"); + lblAuthor.setBounds(10, 42, 46, 14); + playlistDetailsPanel.add(lblAuthor); + playlistAuthorTextField = new JTextField(); + playlistAuthorTextField.setBounds(54, 38, 241, 20); + playlistDetailsPanel.add(playlistAuthorTextField); + playlistAuthorTextField.setColumns(10); + addChangeListener(playlistAuthorTextField); + + // Create playlist size label and text field + JLabel lblLength = new JLabel("Size"); + lblLength.setBounds(305, 16, 46, 14); + playlistDetailsPanel.add(lblLength); + playlistSizeTextField = new JTextField(); + playlistSizeTextField.setText("0"); + playlistSizeTextField.setEditable(false); + playlistSizeTextField.setBounds(364, 12, 72, 20); + playlistDetailsPanel.add(playlistSizeTextField); + playlistSizeTextField.setColumns(10); + + // Create playlist play time label and text field + JLabel lblPlayTime = new JLabel("Play Time"); + lblPlayTime.setBounds(305, 40, 46, 14); + playlistDetailsPanel.add(lblPlayTime); + playlistPlayTimeTextField = new JTextField(); + playlistPlayTimeTextField.setText("0s"); + playlistPlayTimeTextField.setEditable(false); + playlistPlayTimeTextField.setColumns(10); + playlistPlayTimeTextField.setBounds(364, 36, 72, 20); + playlistDetailsPanel.add(playlistPlayTimeTextField); + + // Create the save button + saveButton = new JButton("Save"); + saveButton.setBounds(596, 457, 89, 23); + saveButton.addActionListener(this); + contentPane.add(saveButton); + + // Create the exit button + exitButton = new JButton("Exit"); + exitButton.setBounds(695, 457, 89, 23); + exitButton.addActionListener(this); + contentPane.add(exitButton); + + JPanel panel = new JPanel(); + panel.setBorder(new TitledBorder(UIManager.getBorder("TitledBorder.border"), "Music Playback", + TitledBorder.LEADING, TitledBorder.TOP, null, new Color(0, 0, 0))); + panel.setBounds(338, 360, 356, 86); + contentPane.add(panel); + panel.setLayout(null); + + albumArtPanel = new JPanel(); + albumArtPanel.setBorder(new TitledBorder(UIManager.getBorder("TitledBorder.border"), "", TitledBorder.LEADING, + TitledBorder.TOP, null, new Color(0, 0, 0))); + albumArtPanel.setBounds(704, 366, 78, 78); + contentPane.add(albumArtPanel); + albumArtPanel.setLayout(null); + + JLabel lblAlbumArt = new JLabel(); + lblAlbumArt.setBounds(4, 4, 70, 70); + albumArtPanel.add(lblAlbumArt); + + // Create the music player component + musicPlayer = new MusicPlayer(this.playlist, lblAlbumArt); + musicPlayer.setBounds(13, 16, 330, 64); + panel.add(musicPlayer); + } + + /** + * Button Action listener handler. + */ + @Override + public void actionPerformed(ActionEvent e) { + Object source = e.getSource(); + + if (source == changeDirectoryButton) { + browseDirectory(); + } else if (source == addSongButton) { + addSelectedSongs(); + } else if (source == removeSongButton) { + removeSelectedSongs(); + } else if ((source == saveButton) || (source == mntmSave)) { + save(); + } else if ((source == exitButton) || (source == mntmExit)) { + exit(); + } else if (source == mntmOpen) { + open(); + } else if ((source == upArrowButton) || (source == downArrowButton)) { + reorderSongs(source); + } else if (source == mntmNew) { + createNewPlaylist(null); + } else if (source == mntmSplitTrack) { + splitTrack(); + } else if (source == mntmReleaseNotes) { + displayReleaseNotes(); + } + } + + /** + * Allows the user to browse for a directory to populate the tree view. + */ + public void browseDirectory() { + // The file chooser + JFileChooser fileChooser = new JFileChooser(); + // Allow the user to only be able to select directories + fileChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + + // Show the dialog and if a directory is selected, update the file tree + if (fileChooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + // Update the directory within the file tree + fileTree.setDirectory(fileChooser.getSelectedFile().getAbsolutePath()); + // Update the user defaults with the new directory + this.userDefaults.setDefaultBrowserDir(fileChooser.getSelectedFile().getAbsolutePath()); + } + return; + } + + /** + * Allows the user to browse for a playlist file to be opened. + */ + public void open() { + // The file chooser. Setting the current directory from the user defaults + JFileChooser fileChooser = new JFileChooser(new File( + ((this.userDefaults.getDefaultOpenPlaylistDir() != null) ? this.userDefaults.getDefaultOpenPlaylistDir() + : ""))); + // Create new file filter for the playlist file formats + FileNameExtensionFilter filter = new FileNameExtensionFilter(null, "wpl"); + // Set the filter + fileChooser.setFileFilter(filter); + // Allow the user to only be able to select directories + fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY); + + // Show the dialog and if a directory is selected, update the file tree + if (fileChooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + // Check that the file is of the correct format + if (fileChooser.getSelectedFile().getName().endsWith(".wpl")) { + this.createNewPlaylist(fileChooser.getSelectedFile()); + // Set the default for the location of the directory that this file is in + this.userDefaults.setDefaultOpenPlaylistDir(fileChooser.getSelectedFile().getParent()); + } + } + } + + /** + * Clears the GUI and creates a new playlist instance. + */ + public void createNewPlaylist(File file) { + // If the current playlist isn't yet saved then ask the user if they would like + // to save it + if (!this.playlist.isSaved()) { + // Ask the user if they want to save the current playlist + int saveDialog = JOptionPane.showConfirmDialog(this, "Would you like to save the current playlist?"); + + // If the user confirmed the dialog + if (saveDialog == JOptionPane.YES_OPTION) { + // Save the current playlist + boolean saveSuccessful = this.save(); + // If the save was unsuccessful then exit this method + if (!saveSuccessful) { + return; + } + } + // If the user cancelled the dialog, then do not create new playlist + else if (saveDialog == JOptionPane.CANCEL_OPTION || saveDialog == JOptionPane.CLOSED_OPTION) { + return; + } + } + + // If a file has been specified then pass it into the playlist constructor when + // creating the new playlist + if (file != null) { + try { + this.playlist = new Playlist(file); + } catch (FileNotFoundException e) { + this.raiseFileNotFoundWarning(file.getAbsolutePath()); + } + } else { + this.playlist = new Playlist(); + } + + // Refresh the GUI + this.refreshPlaylistDetails(); + + // Update the music player with the new playlist + this.musicPlayer.updatePlaylist(this.playlist); + } + + /** + * Updates all components that hold information about the playlist with current + * information. + */ + public void refreshPlaylistDetails() { + // Update the playlist details section + this.playlistNameTextField.setText(this.playlist.getName()); + this.playlistAuthorTextField.setText(this.playlist.getAuthor()); + this.playlistSizeTextField.setText(String.valueOf(this.playlist.getSize())); + this.playlistPlayTimeTextField.setText(this.playlist.getPlayTime()); + + // Remove any songs from the songs table + int numOfRows = this.songTable.getModel().getRowCount(); + // Iterate through songs currently in table + for (int i = 0; i < numOfRows; i++) { + // Remove the song from the table + ((DefaultTableModel) this.songTable.getModel()).removeRow(0); + } + + // Update the songs table + for (Song song : this.playlist.getSongs()) { + songTableModel.addRow( + new Object[] { (this.playlist.getSongs().indexOf(song) + 1), song.getArtist(), song.getTitle() }); + } + } + + public void addSong(FileNode nodeOfFile) { + File file = nodeOfFile.getFile(); + + if (file.getName().endsWith(".mp3")) { + try { + // Create song object + Song song = new Song(file.getAbsolutePath()); + // Add the song to the playlist + this.playlist.addSong(song); + + // If the playlist was empty before adding the song then set this song as the + // current one + if (this.playlist.getSize() == 1) { + this.musicPlayer.setCurrentSong(song); + // Refresh the GUI components + this.musicPlayer.updateGUI(); + } + + // Add the song to the table + songTableModel.addRow(new Object[] { this.playlist.getSize(), song.getArtist(), song.getTitle() }); + + // Refresh the playlist details + refreshPlaylistDetails(); + } catch (FileNotFoundException e) { + this.raiseFileNotFoundWarning(file.getAbsolutePath()); + } + } + } + + /** + * Adds the songs that are currently selected in the file tree to the tracks + * table. + */ + public void addSelectedSongs() { + // Check that something has been selected in the file tree + if (fileTree.getSelectionPath() != null) { + for (TreePath tp : fileTree.getSelectionPaths()) { + addSong((FileNode) tp.getLastPathComponent()); + } + } + } + + /** + * Removes the songs that are currently selected in the songs table from the + * playlist. + */ + public void removeSelectedSongs() { + // Get the number of selected rows + int numOfSelectedRows = this.songTable.getSelectedRowCount(); + + // Iterate through the selected rows + for (int i = 0; i < numOfSelectedRows; i++) { + int index = this.songTable.getSelectedRows()[0]; + // Remove the song from the playlist + this.playlist.removeSong(index); + // Remove the song from the table + ((DefaultTableModel) this.songTable.getModel()).removeRow(index); + } + // Refresh the playlist details + refreshPlaylistDetails(); + } + + /** + * Updates the track numbers in the songs table. + */ + public void updateTrackNumbers() { + // Update the track numbers in the songs table + for (int i = 0; i < this.songTable.getModel().getRowCount(); i++) { + this.songTable.getModel().setValueAt((i + 1), i, 0); + } + } + + /** + * Reorders the songs within the songs table upon clicking up or down arrow + * button. + * + * @param source - The source object that trigger the action event. + */ + public void reorderSongs(Object source) { + // Ensure that only one row has been selected + if (this.songTable.getSelectedRowCount() == 1) { + // Get the index of the selected row + int indexOfSelectedRow = this.songTable.getSelectedRow(); + + // Get the table model + DefaultTableModel tableModel = ((DefaultTableModel) this.songTable.getModel()); + + // If the up arrow button was clicked + if (source == this.upArrowButton) { + // Check that the selected row isn't already at the start + if (indexOfSelectedRow != 0) { + // Unselected the selected row + this.songTable.clearSelection(); + // Move the row up in the table + tableModel.moveRow(indexOfSelectedRow, indexOfSelectedRow, (indexOfSelectedRow - 1)); + // Re-select the moved row + this.songTable.setRowSelectionInterval((indexOfSelectedRow - 1), (indexOfSelectedRow - 1)); + // Swap the two rows that have changed position in the playlist + this.playlist.swapSongs(indexOfSelectedRow, (indexOfSelectedRow - 1)); + } + } + // Otherwise, if the down arrow button was clicked + else if (source == this.downArrowButton) { + // Check that the selected row isn't already at the end + if (indexOfSelectedRow != (this.songTable.getRowCount() - 1)) { + // Unselected the selected row + this.songTable.clearSelection(); + // Move the row down in the table + tableModel.moveRow(indexOfSelectedRow, indexOfSelectedRow, (indexOfSelectedRow + 1)); + // Re-select the moved row + this.songTable.setRowSelectionInterval((indexOfSelectedRow + 1), (indexOfSelectedRow + 1)); + // Swap the two rows that have changed position in the playlist + this.playlist.swapSongs(indexOfSelectedRow, (indexOfSelectedRow + 1)); + } + } + // + this.songTable.scrollRectToVisible(this.songTable.getCellRect(this.songTable.getSelectedRow(), 0, true)); + // Update the track numbers in the songs table + updateTrackNumbers(); + } + } + + /** + * Saves the playlist currently being edited. + * + * @return Boolean value indicating whether the save was successful or not. + */ + public boolean save() { + // Check if the playlist has an associated file, if not prompt the user to + // select one + if (playlist.getFile() == null) { + // Display the file system for the user to pick a location to save the playlist + JFileChooser fileChooser = new JFileChooser(); + // Set the file name within the file chooser + fileChooser.setSelectedFile(new File(playlist.getName() + ".wpl")); + // If the user has selected a file + if (fileChooser.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) { + // Fetch the selected file + File chosenFile = fileChooser.getSelectedFile(); + // Check that the file is of the right type + if (chosenFile.getAbsolutePath().endsWith(Playlist.WPL)) { + // If the file already exists, then as the user to confirm overwritting the + // playlist + if (chosenFile.exists()) { + int confirmReplaceFileResult = JOptionPane.showConfirmDialog(this, + chosenFile.getName() + " already exists. Do you want to replace it?", "Confirm Save As", + JOptionPane.YES_NO_OPTION); + // If the user didn't says yes to the popup then exit the method + if (!(confirmReplaceFileResult == JOptionPane.YES_OPTION)) { + return false; + } + } + // Set the playlist's file + this.playlist.setFile(fileChooser.getSelectedFile().getAbsoluteFile()); + } + } else { + return false; + } + } + // Save the playlist + this.playlist.save(Playlist.WPL); + // Display popup + JOptionPane.showMessageDialog(this, "Your playlist has been saved."); + return true; + } + + /** + * Splits the current track within the music player and allows the user to + * export it to a new file. + */ + public void splitTrack() { + // If their isn't currently a song loaded into the music player then raise an + // error message to the user and exit method + if (this.musicPlayer.getCurrentSong() == null) { + JOptionPane.showMessageDialog(this, "Could not split as there is no current song. Play a song to split it.", + "Split Track Failed", JOptionPane.ERROR_MESSAGE); + return; + } + + // Ask the user whether they want to keep the first or second part of the + // current track + int splitChoice = JOptionPane.showOptionDialog(this, + "Would you like to export the first or second part of the split track?", "Split Track Tool", + JOptionPane.YES_NO_OPTION, JOptionPane.INFORMATION_MESSAGE, null, + new Object[] { "First Part", "Second Part" }, null); + + // If the user closed the dialog then exit method + if (splitChoice == JOptionPane.CLOSED_OPTION) { + return; + } + + // Ask the user to select a file to write the new split track to + JFileChooser fileChooser = new JFileChooser(new File( + ((this.userDefaults.getDefaultOpenPlaylistDir() != null) ? this.userDefaults.getDefaultOpenPlaylistDir() + : ""))); + // Create new file filter for mp3 file format + FileNameExtensionFilter filter = new FileNameExtensionFilter(null, "mp3"); + // Set the filter + fileChooser.setFileFilter(filter); + // Allow the user to only be able to select files + fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY); + + // Show the dialog and if a directory is selected, update the file tree + if (fileChooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + // The file chosen by the user + File selectedFile = fileChooser.getSelectedFile(); + + // Check that the file is of the correct format + if (selectedFile.getName().endsWith(".mp3")) { + // If the file already exists, ask the user if they want to replace it + if (selectedFile.exists()) { + int replaceChoice = JOptionPane.showConfirmDialog(this, + "The file that you have selected already exists, would you like to replace it?", + "Confirm Replace", JOptionPane.YES_NO_CANCEL_OPTION); + // If the users didn't agree to replace the file then exit the method + if (replaceChoice != JOptionPane.YES_OPTION) { + return; + } + } + + // Split the track at the specified points and write the output to the specified + // file + this.musicPlayer.splitCurrentTrackAndWriteToFile(selectedFile, (splitChoice == 0) ? true : false); + } else { + JOptionPane.showMessageDialog(this, "Unable to perform split. Export file must of mp3 format.", + "Incorrect Export Format", JOptionPane.ERROR_MESSAGE); + } + } + } + + /** + * Raises an error dialog informing the user of a resource not being found. + * + * @param The name of the resource that could not be found. + */ + public void raiseFileNotFoundWarning(String fileName) { + // The error message to be displayed to the user + String message; + + if (fileName.endsWith(".wpl")) { + message = "The playlist or a track within the following playlist file could not be found:"; + } else { + message = "The following file could not be found:"; + } + // Display the error message dialog + JOptionPane.showMessageDialog(this, message + "\n" + fileName, "File Not Found", JOptionPane.ERROR_MESSAGE); + } + + /** + * Displays a popup containing the release notes + */ + private void displayReleaseNotes() { + // Fetch the text from the release notes file + String releaseNotes = new String(); + try { + InputStream input = getClass().getResourceAsStream("/resources/release notes.txt"); + + BufferedReader br = new BufferedReader(new InputStreamReader(input)); + + StringBuilder sb = new StringBuilder(); + + String line = br.readLine(); + + while (line != null) { + sb.append(line); + sb.append(System.lineSeparator()); + line = br.readLine(); + } + releaseNotes = sb.toString(); + + br.close(); + } catch (IOException e) { + e.printStackTrace(); + } + // The text pane object containing the release notes + JTextPane releaseNotesTextPane = new JTextPane(); + releaseNotesTextPane.setPreferredSize(new Dimension(350, 600)); + releaseNotesTextPane.setFont(new Font(Font.SANS_SERIF, 0, 12)); + releaseNotesTextPane.setText(releaseNotes); + releaseNotesTextPane.setEditable(false); + releaseNotesTextPane.setCaretPosition(0); + + JScrollPane releaseNotesScrollPane = new JScrollPane(releaseNotesTextPane); + releaseNotesScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + releaseNotesScrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); + + // Display a popup containing the release notes text pane + JOptionPane.showMessageDialog(this, releaseNotesScrollPane, "Release Notes", JOptionPane.PLAIN_MESSAGE); + } + + /** + * Closes the program. + */ + public void exit() { + // If the current playlist isn't saved then prompt the user to save it. + if (!this.playlist.isSaved()) { + // Ask the user if they want to save the current playlist + int saveDialog = JOptionPane.showConfirmDialog(this, + "Would you like to save the current playlist before exiting?"); + + // If the user confirmed the dialog + if (saveDialog == JOptionPane.YES_OPTION) { + // Save the current playlist + boolean saveSuccessful = this.save(); + // If the save was unsuccessful then exit this method without exiting + if (!saveSuccessful) { + return; + } + } + // If the user cancelled the popup then cancelled the exiting sequence + else if (saveDialog == JOptionPane.CANCEL_OPTION || saveDialog == JOptionPane.CLOSED_OPTION) { + return; + } + } + // Save the user defaults file + this.userDefaults.saveDefaultsToFile(); + // Exit the program + System.exit(0); + } + + /** + * Adds a document listener to the document of a textfield so that a change to + * the contents of the textfield calls the textFieldChanged method. + * + * @param textField - The JTextField to add the listener to. + */ + public void addChangeListener(JTextField textField) { + // Create the document listener + DocumentListener dl = new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + textFieldChange(textField); + } + + @Override + public void removeUpdate(DocumentEvent e) { + textFieldChange(textField); + } + + @Override + public void changedUpdate(DocumentEvent e) { + textFieldChange(textField); + } + }; + // Add the document listener to the text field's document + textField.getDocument().addDocumentListener(dl); + } + + /** + * Called when the author or playlist title text fields are edited. + */ + private void textFieldChange(JTextField source) { + // Update the corresponding playlist value depending up the source of the method + // call + if (source == this.playlistNameTextField) { + this.playlist.setName(this.playlistNameTextField.getText()); + } else if (source == this.playlistAuthorTextField) { + this.playlist.setAuthor(this.playlistAuthorTextField.getText()); + + } + } +} diff --git a/src/com/pe/Song.java b/src/com/pe/Song.java new file mode 100644 index 0000000..8c6cb73 --- /dev/null +++ b/src/com/pe/Song.java @@ -0,0 +1,110 @@ +package com.pe; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import javax.imageio.ImageIO; +import com.mpatric.mp3agic.ID3v2; +import com.mpatric.mp3agic.InvalidDataException; +import com.mpatric.mp3agic.Mp3File; +import com.mpatric.mp3agic.UnsupportedTagException; + +/** + * Represents a song. + * + * @author olly.rowe + */ +public class Song +{ + // The file associated with this song + private File file; + // Song title + private String title; + // The artist + private String artist; + // The album + private String album; + // Length in milliseconds + private long length; + // The album art + private BufferedImage albumArt; + + /** + * Class Constructor. + * + * @param path - The string value of the absolute path to the .mp3 file + */ + public Song(String path) throws FileNotFoundException + { + // Set the file + this.file = new File(path); + // Attempt to extract metadata from the file + try + { + // Utilise MP3agic module to read the ID3v2 tag + Mp3File song = new Mp3File(this.getFile().getAbsolutePath()); + + if (song.hasId3v2Tag()) + { + // Fetch the ID3v2 tag + ID3v2 id3v2tag = song.getId3v2Tag(); + // Fetch and assign values from the ID3v2 tag + this.title = id3v2tag.getTitle(); + this.artist = id3v2tag.getArtist(); + this.album = id3v2tag.getAlbum(); + this.length = song.getLengthInSeconds(); + // Fetch the album image data + byte[] imageData = id3v2tag.getAlbumImage(); + // Convert the bytes to an image if the data has been found + if (imageData != null) + { + this.albumArt = ImageIO.read(new ByteArrayInputStream(imageData)); + } + } + } + catch (FileNotFoundException e) + { + throw new FileNotFoundException(); + } + catch (IOException | InvalidDataException | UnsupportedTagException e) + { + e.printStackTrace(); + } + // Assign default values for variables that have not yet been set + this.title = (this.title == null) ? (this.file.getName().substring(0, this.file.getName().length() - 4)) : this.title; + this.artist = (this.artist == null) ? "unknown artist" : this.artist; + this.album = (this.album == null) ? "unknown album" : this.album; + } + + public String getTitle() + { + return this.title; + } + + public String getArtist() + { + return this.artist; + } + + public String getAlbum() + { + return this.album; + } + + public long getLength() + { + return this.length; + } + + public File getFile() + { + return this.file; + } + + public BufferedImage getAlbumArt() + { + return this.albumArt; + } +} diff --git a/src/com/pe/UserDefaults.java b/src/com/pe/UserDefaults.java new file mode 100644 index 0000000..ab6f508 --- /dev/null +++ b/src/com/pe/UserDefaults.java @@ -0,0 +1,179 @@ +package com.pe; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.StringWriter; +import java.io.PrintWriter; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.SAXException; + +/** + * Class that creates and interacts with defaults.conf file that stores useful user default values. + * + * @author olly.rowe + * + */ +public class UserDefaults +{ + // The defaults.conf file name + private static final String FILE_NAME = "defaults.conf"; + + // The defaults file + private File file; + + // The default directory for the 'Browse for Music' section + private String defaultBrowserDir; + + // The default directoy when browsing to open a playlist + private String defaultOpenPlaylistDir; + + /** + * Class Constructor. Will detect existing defaults.conf file and read it. + */ + public UserDefaults() + { + // Initialise the defaults file + this.file = new File(FILE_NAME); + + // If the file exists, then attempt to parse it + if (this.file.exists()) + { + try + { + DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder docBuilder = docFactory.newDocumentBuilder(); + Document doc = docBuilder.parse(this.file); + + Node defaultBrowserDirNode = doc.getElementsByTagName("defaultBrowserDir").item(0); + + // Set the default browser dir variable if the text content isn't empty + this.defaultBrowserDir = (defaultBrowserDirNode.getTextContent().equals("")) ? null : defaultBrowserDirNode.getTextContent(); + + Node defaultOpenPlaylistDirNode = doc.getElementsByTagName("defaultOpenPlaylistDir").item(0); + + // Set the default open playlist dir variable if the text content isn't empty + this.defaultOpenPlaylistDir = (defaultOpenPlaylistDirNode.getTextContent().equals("")) ? null : defaultOpenPlaylistDirNode.getTextContent(); + } + catch (NullPointerException e) + { + // If a null pointer error is raised then the file is missing an item of data, ignore this + return; + } + catch (ParserConfigurationException | SAXException | IOException e) + { + e.printStackTrace(); + } + } + } + + /** + * Getter for default browser directory string value. + * + * @return The String value of the default browser directory. + */ + public String getDefaultBrowserDir() + { + return this.defaultBrowserDir; + } + + /** + * Setter for the default browser directory. + * + * @param defaultBrowserDir - The string value of the path of the default browser directory. + */ + public void setDefaultBrowserDir(String path) + { + this.defaultBrowserDir = path; + } + + /** + * Getter for the default open playlist directory string value. + * + * @return The String value of the default directory for opening a playlist. + */ + public String getDefaultOpenPlaylistDir() + { + return this.defaultOpenPlaylistDir; + } + + /** + * Setter for the default open playlist directory. + * + * @param path - The string value of the path of the default browser directory. + */ + public void setDefaultOpenPlaylistDir(String path) + { + this.defaultOpenPlaylistDir = path; + } + + /** + * Saves the current defaults to the defaults.conf file. + */ + public void saveDefaultsToFile() + { + // Attempt to parse defaults.conf file + try + { + // Adapted from: https://examples.javacodegeeks.com/core-java/xml/parsers/documentbuilderfactory/create-xml-file-in-java-using-dom-parser-example/ + DocumentBuilderFactory documentFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder documentBuilder = documentFactory.newDocumentBuilder(); + Document document = documentBuilder.newDocument(); + + Element defaultsElement = document.createElement("Defaults"); + + document.appendChild(defaultsElement); + + Element defaultBrowserDirElement = document.createElement("defaultBrowserDir"); + + if (this.defaultBrowserDir != null) + { + defaultBrowserDirElement.appendChild(document.createTextNode(this.defaultBrowserDir)); + } + + defaultsElement.appendChild(defaultBrowserDirElement); + + Element defaultOpenPlaylistDirElement = document.createElement("defaultOpenPlaylistDir"); + + if (this.defaultOpenPlaylistDir != null) + { + defaultOpenPlaylistDirElement.appendChild(document.createTextNode(this.defaultOpenPlaylistDir)); + } + + defaultsElement.appendChild(defaultOpenPlaylistDirElement); + + // Create new transformation objects + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + DOMSource domSource = new DOMSource(document); + // Create a new StringWriter object to output the result to + StringWriter outWriter = new StringWriter(); + // The stream result, outputting to the String Writer + StreamResult streamResult = new StreamResult(outWriter); + // Transform the DOM Object to an XML File + transformer.transform(domSource, streamResult); + StringBuffer sb = outWriter.getBuffer(); + + // Write the output to the file + PrintWriter pw = new PrintWriter(this.file.getAbsolutePath()); + pw.println(sb.toString()); + pw.close(); + } + catch (ParserConfigurationException | TransformerException | FileNotFoundException e) + { + e.printStackTrace(); + } + } +} diff --git a/src/com/pe/audio/MusicPlayer.java b/src/com/pe/audio/MusicPlayer.java new file mode 100644 index 0000000..e9de94a --- /dev/null +++ b/src/com/pe/audio/MusicPlayer.java @@ -0,0 +1,556 @@ +package com.pe.audio; + +import com.pe.Song; +import com.pe.Playlist; + +import java.awt.Image; +import java.awt.Point; +import java.awt.SystemColor; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Set; + +import javax.swing.BorderFactory; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSlider; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.plaf.basic.BasicSliderUI; + +import javazoom.jl.decoder.JavaLayerException; +import javazoom.jl.player.Player; + +/** + * Represent the music player component. + * + * @author olly.rowe + */ +@SuppressWarnings("serial") +public class MusicPlayer extends JPanel implements ActionListener +{ + // The javazoom player object + private Player player; + + // The thread object that the player will run within + private Thread playerThread; + + // The member variables associated with the player + private FileInputStream stream; + private long totalSongLength; + + // Whether the player is currently in a paused state + private volatile boolean isPaused = false; + + // The song that is currently playing + private Song currentSong; + + // The playlist being played + private Playlist playlist; + + // The song title text field + private JTextField txtSongTitle; + + // The button components + private JButton btnSkipBackSong; + private JButton btnPlayPause; + private JButton btnSkipForwardSong; + + // The song progress bar + private JSlider progressBar; + + // Play icon + private final Image PLAY_ICON = (new ImageIcon(getClass().getResource("/images/play.png"))).getImage().getScaledInstance(12, 12, Image.SCALE_SMOOTH); + // Pause icon + private final Image PAUSE_ICON = (new ImageIcon(getClass().getResource("/images/pause.png"))).getImage().getScaledInstance(12, 12, Image.SCALE_SMOOTH); + + // The album art JPanel component + private JLabel albumArtLabel; + + // The default album art cover + private final Image DEFAULT_ALBUM_ART_ICON = (new ImageIcon(getClass().getResource("/images/default album art.png"))).getImage().getScaledInstance(70, 70, Image.SCALE_SMOOTH); + + /** + * Class Constructor. + * + * @param playlist - The playlist of songs to be played. + * @param albumArtPanel - The JLabel where the album art will appear. + */ + public MusicPlayer(Playlist playlist, JLabel albumArtLabel) + { + super(); + + // Set the playlist + this.playlist = playlist; + // Set the album art label + this.albumArtLabel = albumArtLabel; + + // Remove the layout + this.setLayout(null); + + // Set the default album art icon + this.albumArtLabel.setIcon(new ImageIcon(DEFAULT_ALBUM_ART_ICON)); + + // Set up skip backwards button + Image skipBackwardsIcon = (new ImageIcon(getClass().getResource("/images/skip backwards.png"))).getImage().getScaledInstance(15, 15, Image.SCALE_SMOOTH); + btnSkipBackSong = new JButton(new ImageIcon(skipBackwardsIcon)); + btnSkipBackSong.addActionListener(this); + btnSkipBackSong.setBounds(10, 20, 80, 23); + add(btnSkipBackSong); + + // Set up play / pause button + btnPlayPause = new JButton(new ImageIcon(PLAY_ICON)); + btnPlayPause.addActionListener(this); + btnPlayPause.setBounds(136, 20, 58, 23); + add(btnPlayPause); + + // Set up skip backwards button + Image skipForwardsIcon = (new ImageIcon(getClass().getResource("/images/skip forwards.png"))).getImage().getScaledInstance(15, 15, Image.SCALE_SMOOTH); + btnSkipForwardSong = new JButton(new ImageIcon(skipForwardsIcon)); + btnSkipForwardSong.addActionListener(this); + btnSkipForwardSong.setBounds(240, 20, 80, 23); + add(btnSkipForwardSong); + + // Set up the song title and artist text field + txtSongTitle = new JTextField(); + txtSongTitle.setBorder(BorderFactory.createEmptyBorder()); + txtSongTitle.setHorizontalAlignment(SwingConstants.CENTER); + txtSongTitle.setEditable(false); + txtSongTitle.setBackground(SystemColor.menu); + txtSongTitle.setBounds(10, 0, 310, 18); + add(txtSongTitle); + + // Set up the song progress bar. Removes listeners that disallow the user to seek a position on the slider by clicking at a certain point. + progressBar = new JSlider() { + { + MouseListener[] listeners = getMouseListeners(); + for (MouseListener l : listeners) + removeMouseListener(l); // remove UI-installed TrackListener + final BasicSliderUI ui = (BasicSliderUI) getUI(); + BasicSliderUI.TrackListener tl = ui.new TrackListener() + { + @Override + public void mouseReleased(MouseEvent e) + { + Point p = e.getPoint(); + int value = ui.valueForXPosition(p.x); + setValue(value); + if (!isPaused) + { + pause(); + setValue(value); + resume(); + } + } + + @Override public boolean shouldScroll(int dir) { + return false; + } + }; + addMouseListener(tl); + } + }; + + // Set the value to 0 by default + progressBar.setValue(0); + + progressBar.setBounds(10, 43, 310, 24); + add(progressBar); + } + + /** + * Button Action listener handler. + */ + @Override + public void actionPerformed(ActionEvent e) + { + // Ensure that a playlist has been loaded and isn't empty + Object source = e.getSource(); + + if (source == btnSkipBackSong) + { + skipBackSong(); + } + else if (source == btnPlayPause) + { + playPauseSong(); + } + else if (source == btnSkipForwardSong) + { + skipForwardSong(); + } + } + + /** + * Toggles between playing and pausing the current song. + */ + public void playPauseSong() + { + if (this.player != null) + { + if (this.isPaused) + { + this.resume(); + } + else + { + this.pause(); + } + } + else + { + // Check that the playlist isn't empty + if (this.playlist.getSong(0) != null) + { + // If the player is not yet initialised, default the current song to the first one in the playlist + this.currentSong = this.playlist.getSongs().get(0); + // Play the song from the start + this.playSong(0); + } + } + } + + /** + * Plays the current song. + * + * @param startPoint - The point within the song to start playing form. + */ + private void playSong(long startPoint) + { + // Check that there is a current song to play + if (this.currentSong != null) + { + this.isPaused = false; + + if (player != null) + { + player.close(); + player = null; + } + // Attempt to play the audio file + try + { + this.stream = new FileInputStream(this.currentSong.getFile()); + + this.totalSongLength = this.stream.available(); + + this.progressBar.setMaximum((int) this.totalSongLength); + + this.stream.skip(startPoint); + + player = new Player(this.stream); + + this.playerThread = new Thread("Music Player") + { + public void run() + { + try + { + updateGUI(); + + btnPlayPause.setIcon(new ImageIcon(PAUSE_ICON)); + + player.play(); + + // Once the player is complete, skip to the next song in the playlist + if (player != null && player.isComplete()) + { + skipForwardSong(); + } + } + catch (JavaLayerException e) + { + e.printStackTrace(); + } + } + }; + + this.playerThread.start(); + + // Get all currently running threads + Set threadSet = Thread.getAllStackTraces().keySet(); + + // End method if there is currently a progress bar updater thread running + for (Thread thread : threadSet) + { + if (thread.getName().equals("Progress Bar Updater")) + { + return; + } + } + + // Create a new thread to update the progress bar + Thread updateProgressBarThread = new Thread("Progress Bar Updater") { + public void run() + { + // maybe check if one of these threads is already running as they can double up when you navigate through a song using progress bar + while (!this.isInterrupted()) + { + // If player's state changes before song ends then catch the null pointer exception + try + { + if (player.isComplete() || isPaused) + { + break; + } + + // Update the progress bar + updateProgressBar(); + + Thread.sleep(100); + } + catch (NullPointerException | InterruptedException e) + { + break; + } + } + } + }; + + updateProgressBarThread.start(); + } + catch (JavaLayerException | IOException e) + { + e.printStackTrace(); + } + } + } + + /** + * Plays the current song from the beginning. + */ + private void playSong() + { + this.playSong(this.progressBar.getValue()); + } + + /** + * Pauses the song currently being played in the player. + */ + private void pause() + { + this.btnPlayPause.setIcon(new ImageIcon(PLAY_ICON)); + + if (player != null) + { + this.player.close(); + } + this.isPaused = true; + } + + /** + * Resumes a paused song in the player. + */ + private void resume() + { + // Play the song from pause location + playSong(this.progressBar.getValue()); + } + + /** + * Skips the song currently being played back to the previous one in the playlist. + */ + private void skipBackSong() + { + // Check that the first song in the playlist isn't currently being played + if (this.currentSong != this.playlist.getSong(0) && this.playlist.getSize() > 0) + { + // Change the current song to the previous on in the playlist + this.currentSong = this.playlist.getSong(this.playlist.getIndexOf(this.currentSong) - 1); + } + // Play the current song from the start + this.playSong(0); + } + + /** + * Skips the song currently being played forward to the next one in the playlist. + */ + public void skipForwardSong() + { + // Check that the playlist has a final song before doing anything + if (this.playlist.getLastSong() != null) + { + // Check that the final song in the playlist isn't currently being played + if (this.currentSong != this.playlist.getLastSong()) + { + // Update the current song to the previous one in the playlist + this.currentSong = this.playlist.getSong(this.playlist.getIndexOf(this.currentSong) + 1); + // Play the next song + this.playSong(0); + } + // Otherwise, go into a paused state + else + { + // Enter paused state + this.pause(); + // Reset the progress bar + this.progressBar.setValue(0); + } + } + } + + /** + * Splits the current song at the current progress into two. + */ + public void splitCurrentTrackAndWriteToFile(File outputFile, boolean outputPreSplitPoint) + { + // Check that there is a current track + if (this.currentSong != null) + { + try + { + // Create a new temporary input stream for the current song + FileInputStream tempInputStream = new FileInputStream(this.currentSong.getFile()); + + int startPoint, endPoint; + + // Calculate the start and end points based upon argument + if (outputPreSplitPoint) + { + startPoint = 0; + endPoint = this.progressBar.getValue(); + } + else + { + startPoint = this.progressBar.getValue(); + endPoint = (int) this.totalSongLength; + } + + // Skip to the start point + tempInputStream.skip((long) startPoint); + + // Create a new output stream to the output file + FileOutputStream outputStream = new FileOutputStream(outputFile); + + // The number of remaining bytes to be read and outputted + int remainingBytes = endPoint - startPoint; + + // Create the buffer to be written to + byte[] buffer = new byte[remainingBytes]; + + // Populate contents of the buffer from the input stream + tempInputStream.read(buffer); + // Write the contents of the buffer to the output stream + outputStream.write(buffer); + + // Close the opened streams + tempInputStream.close(); + outputStream.close(); + } + catch (IOException e) + { + e.printStackTrace(); + return; + } + } + } + + /** + * Updates the current playlist associated with this music player with a new one. + * + * @param playlist - The new playlist. + */ + public void updatePlaylist(Playlist playlist) + { + // Update the playlist member variable + this.playlist = playlist; + + if (playlist.getSize() > 0) + { + // Get the first song from the playlist + Song firstSong = playlist.getSongs().get(0); + + // Set the current song + this.currentSong = firstSong; + + } + + // Update the GUI components + this.updateGUI(); + } + + /** + * Updates the progress bar with the current song progress. + */ + public void updateProgressBar() + { + int progress; + try + { + // Calculate progress as the total length of the song minus the length of song left + progress = (int) (this.totalSongLength - this.stream.available()); + } + catch (IOException e) + { + return; + } + // Update the progress bar value + this.progressBar.setValue(progress); + } + + /** + * Updates the GUI components with the details of the current song. + */ + public void updateGUI() + { + // Update the album art + try + { + this.albumArtLabel.setIcon(new ImageIcon(new ImageIcon(this.currentSong.getAlbumArt()).getImage().getScaledInstance(70, 70, Image.SCALE_SMOOTH))); + } + catch (NullPointerException e) + { + this.albumArtLabel.setIcon(new ImageIcon(DEFAULT_ALBUM_ART_ICON)); + } + + // Update the song title and artist text field with the details of the current song + this.txtSongTitle.setText(this.currentSong.getTitle() + " - " + this.currentSong.getArtist()); + } + + /** + * Sets the current song. Stops the current song from playing and resets the progress bar. + * + * @param the song to be set as the current song. + */ + public void setCurrentSong(Song song) + { + // The the current song + this.currentSong = song; + // Pause the song that is currently playing + this.pause(); + // Reset the progress bar + this.progressBar.setValue(0); + // Update the max value + try + { + // Open a new temporary stream + FileInputStream tempStream = new FileInputStream(this.currentSong.getFile()); + // Set the max value + this.progressBar.setMaximum((int) tempStream.available()); + // Close the stream + tempStream.close(); + } + catch (IOException e) + { + return; + } + } + + /** + * Gets the current song. + * + * @return the current song. + */ + public Song getCurrentSong() + { + return this.currentSong; + } +} \ No newline at end of file diff --git a/src/com/pe/utils/FileNode.java b/src/com/pe/utils/FileNode.java new file mode 100644 index 0000000..4647a5d --- /dev/null +++ b/src/com/pe/utils/FileNode.java @@ -0,0 +1,84 @@ +package com.pe.utils; + +import java.io.File; +import javax.swing.Icon; +import javax.swing.tree.DefaultMutableTreeNode; + +/** + * Represents a node within the file tree. + */ +@SuppressWarnings("serial") +public class FileNode extends DefaultMutableTreeNode +{ + // The corresponding file + private File file; + + // Boolean value identifying whether this node has yet been mapped + private boolean isMapped = false; + + // The icon associated with this node that is called when rendering + private Icon icon; + + /** + * Class constructor. + * + * @param file - The associated file to this node. + */ + public FileNode(File file) + { + // Call to super constructor. Pass either the name of the file or, for the root, pass the path + super(file.getName().equals("") ? file.getAbsolutePath() : file.getName()); + + this.file = file; + } + + /** + * Gets the file. + * + * @return The corresponding file to this node. + */ + public File getFile() + { + return this.file; + } + + /** + * Sets the state of isMapped. + * + * @param isMapped Whether this node is mapped. + */ + public void setMapped(boolean isMapped) + { + this.isMapped = isMapped; + } + + /** + * Returns whether this node is yet mapped. + * + * @return State of isMapped attribute. + */ + public boolean isMapped() + { + return this.isMapped; + } + + /** + * Gets the associated icon. + * + * @return the associated icon. + */ + public Icon getIcon() + { + return this.icon; + } + + /** + * Sets the associated icon. + * + * @param icon - the icon to be set. + */ + public void setIcon(Icon icon) + { + this.icon = icon; + } +} \ No newline at end of file diff --git a/src/com/pe/utils/FileTree.java b/src/com/pe/utils/FileTree.java new file mode 100644 index 0000000..4734f31 --- /dev/null +++ b/src/com/pe/utils/FileTree.java @@ -0,0 +1,277 @@ +package com.pe.utils; + +import java.awt.Component; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JTree; +import javax.swing.UIManager; +import javax.swing.event.TreeExpansionEvent; +import javax.swing.event.TreeExpansionListener; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; + +/** + * FileTree.java + * + * Represents a JTree that displays a given file system. + * + * Threading has been utilised to map the file system using a breadth-first search. + * Nodes can also be mapped on an as-needed bases to avoid long loading times. + */ +@SuppressWarnings("serial") +public class FileTree extends JTree implements TreeExpansionListener +{ + // The array of file types to be displayed in the tree + private ArrayList fileTypesToShow; + + /** + * Class constructor. + * + * @param directory - String value of the path of the directory to be set. + * @param fileTypesToShow - array of strings indicating file types to be included. + */ + public FileTree(String directory, List fileTypesToShow) + { + super(new FileNode(new File(directory))); + + this.fileTypesToShow = new ArrayList(fileTypesToShow); + + // Map the root node + this.mapNodes((FileNode) this.getModel().getRoot()); + + // Add tree expansion listener + this.addTreeExpansionListener(this); + + // Set the cell renderer for assignment of node icons + this.setCellRenderer(new DefaultTreeCellRenderer() + { + @Override + public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, boolean leaf, int row, boolean hasFocus) + { + super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); + + FileNode node = (FileNode) value; + + // If an icon has been set for this node then set it + if (node.getIcon() != null) + { + setIcon(node.getIcon()); + } + return this; + } + }); + } + + /** + * Alternative Class Constructor. By default, will display all file types. + * + * @param directory - String value of the path of the directory to be set. + */ + public FileTree(String directory) + { + this(directory, null); + } + + /** + * Maps a node, discovers and creates child nodes. + * + * @param parent - the node to be mapped. + */ + private void mapNodes(FileNode node) + { + // List all child files / directories within parent node + File[] children = node.getFile().listFiles(); + + if (children != null) + { + // Iterate through each child + for (File child : children) + { + if (child.isFile()) + { + if (!this.isValidType(child)) + { + continue; + } + } + // Create a new node + FileNode childNode = new FileNode(child); + // Add the new node to the parent node + node.add(childNode); + + // If this child node is a directory + if (child.isDirectory()) + { + // Set the icon of the child not to the default folder + childNode.setIcon(UIManager.getIcon("Tree.openIcon")); + } + } + } + } + + /** + * Fired upon expansion of a node. Maps the children of the children of the expanded node. + * + * @param e - The event object + */ + @Override + public void treeExpanded(TreeExpansionEvent e) + { + // Fetch the last selected node + FileNode node = (FileNode) e.getPath().getLastPathComponent(); + + // Check if the node is not yet mapped + if (!node.isMapped()) + { + // Iterate through each child node + for (int i = 0; i < node.getChildCount(); i++) + { + FileNode child = (FileNode) node.getChildAt(i); + // Map the child's children + this.mapNodes(child); + } + } + // Set the node to mapped + node.setMapped(true); + } + + /** + * Fired upon collapsing of a node. + * + * @param e - The event object + */ + @Override + public void treeCollapsed(TreeExpansionEvent e) + { + return; + } + + /** + * Changes the directory. + * + * @param directory - String value of the path of the directory to be set. + */ + public void setDirectory(String directory) + { + // Get the tree model + DefaultTreeModel model = (DefaultTreeModel) this.getModel(); + // Set the new root node + model.setRoot(new FileNode(new File(directory))); + // Refresh the GUI + model.reload(); + // Map the child nodes of the new root + this.mapNodes((FileNode) this.getModel().getRoot()); + } + + /** + * Check that the file's type is allow by the file type filters. If there are not filters then it shall return true. + * + * @return - the Boolean value of whether the file's type is valid or not. + */ + private boolean isValidType(File file) + { + if (this.fileTypesToShow != null) + { + // Iterate through each valid file type + for (String fileType : this.fileTypesToShow) + { + // If the file type is valid + if (file.getName().endsWith(fileType)) + { + return true; + } + } + } + // If the file types to show array was null or the for loop did not return true at any point then return false + return false; + } +} + +/** + * FilteredFileTree. + * + * An extension of the FileTree class that allows the user to pass an array list of String that specify the file types to be displayed in the file tree. + * + * The performance of this current implementation is low and requires threading to be implemented to fix this issue. + */ +@SuppressWarnings("serial") +class FilteredFileTree extends FileTree +{ + // Filters for any specific file types to be displayed + private ArrayList fileTypesFilter; + + /** + * Class Constructor. + * + * @param directory - String value of the path of the directory to be set. + * @param fileTypesFilter - String array containing the types of files to be shown in the file tree. + */ + public FilteredFileTree(String directory, ArrayList fileTypesFilter) + { + super(directory); + + this.fileTypesFilter = fileTypesFilter; + } + + /** + * Maps the child node into this current tree. Filtering out unspecified file types. + * + * @param parent - the parent file node. + */ + @SuppressWarnings("unused") + private void mapNodes(FileNode parent) + { + // List all child files / directories within parent node + File[] children = parent.getFile().listFiles(); + + if (children != null) + { + // Iterate through each child + for (File child : children) + { + // If the child is a file then perform type validation check + if (child.isFile()) + { + // Ensure that the child is of one of the types specified in the type filters. If not then continue for loop. + if (!isValidType(child)) + { + continue; + } + } + // Create a new node + FileNode node = new FileNode(child); + // Add the new node to the parent node + parent.add(node); + } + } + } + + /** + * Check that the file's type is allow by the file type filters. If there are not filters then it shall return true. + * + * @return - the Boolean value of whether the file's type is valid or not. + */ + private boolean isValidType(File file) + { + if (this.fileTypesFilter == null) + { + return true; + } + else + { + // Iterate through each valid file type + for (String fileType : this.fileTypesFilter) + { + // If the file type is valid + if (file.getName().endsWith(fileType)) + { + return true; + } + } + // If the for loop did not return true at any point then the file type is invalid. + return false; + } + } +} diff --git a/src/com/pe/utils/SetupWorker.java b/src/com/pe/utils/SetupWorker.java new file mode 100644 index 0000000..1e18bbd --- /dev/null +++ b/src/com/pe/utils/SetupWorker.java @@ -0,0 +1,31 @@ +package com.pe.utils; + +import javax.swing.SwingWorker; + +/** + * SetupWorker.java + * + * Handles set-up processes in the background. + * + * @author olly.rowe + */ +public class SetupWorker extends SwingWorker +{ + + /** + * Class Constructor. + */ + public SetupWorker() + { + super(); + } + + /** + * Inherited SwingWorker method. Called upon .execute() + */ + @Override + protected Object doInBackground() throws Exception + { + return null; + } +} diff --git a/src/images/arrow down.png b/src/images/arrow down.png new file mode 100644 index 0000000..644b44b Binary files /dev/null and b/src/images/arrow down.png differ diff --git a/src/images/arrow up.png b/src/images/arrow up.png new file mode 100644 index 0000000..f3b227f Binary files /dev/null and b/src/images/arrow up.png differ diff --git a/src/images/default album art.png b/src/images/default album art.png new file mode 100644 index 0000000..95eda7c Binary files /dev/null and b/src/images/default album art.png differ diff --git a/src/images/icon.png b/src/images/icon.png new file mode 100644 index 0000000..469d920 Binary files /dev/null and b/src/images/icon.png differ diff --git a/src/images/music note.png b/src/images/music note.png new file mode 100644 index 0000000..599e02d Binary files /dev/null and b/src/images/music note.png differ diff --git a/src/images/pause.png b/src/images/pause.png new file mode 100644 index 0000000..361b931 Binary files /dev/null and b/src/images/pause.png differ diff --git a/src/images/play next button.png b/src/images/play next button.png new file mode 100644 index 0000000..70e0f23 Binary files /dev/null and b/src/images/play next button.png differ diff --git a/src/images/play.png b/src/images/play.png new file mode 100644 index 0000000..747e191 Binary files /dev/null and b/src/images/play.png differ diff --git a/src/images/skip backwards.png b/src/images/skip backwards.png new file mode 100644 index 0000000..fb141ca Binary files /dev/null and b/src/images/skip backwards.png differ diff --git a/src/images/skip forwards.png b/src/images/skip forwards.png new file mode 100644 index 0000000..c8b88d2 Binary files /dev/null and b/src/images/skip forwards.png differ