/* * PlayList.java * * Copyright (C) 2006-2012 Gabriel Burca (gburca dash virtmus at ebixio dot com) * * 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 2 * 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, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package com.ebixio.virtmus; import com.ebixio.util.Log; import com.ebixio.util.NotifyUtil; import com.ebixio.util.PropertyChangeSupportUnique; import com.ebixio.util.Util; import com.ebixio.virtmus.filefilters.PlayListFilter; import com.ebixio.virtmus.options.Options; import com.ebixio.virtmus.stats.StatsLogger; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.annotations.XStreamAlias; import com.thoughtworks.xstream.annotations.XStreamAsAttribute; import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider; import com.thoughtworks.xstream.io.xml.TraxSource; import java.awt.Component; import java.awt.Frame; import java.awt.Graphics; import java.beans.PropertyChangeListener; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.logging.Level; import java.util.logging.LogRecord; import javax.swing.Icon; import javax.swing.JFileChooser; import javax.swing.JOptionPane; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerConfigurationException; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; import org.netbeans.spi.actions.AbstractSavable; import org.openide.filesystems.FileObject; import org.openide.loaders.SaveAsCapable; import org.openide.util.Exceptions; import org.openide.util.ImageUtilities; import org.openide.util.NbPreferences; import org.openide.windows.WindowManager; /** * * @author Gabriel Burca <gburca dash virtmus at ebixio dot com> */ @XStreamAlias("PlayList") public class PlayList implements Comparable<PlayList> { @XStreamAlias("SongFiles") public ArrayList<File> songFiles = new ArrayList<>(); @XStreamAlias("Name") private String name = null; @XStreamAlias("Tags") private String tags = null; @XStreamAlias("Notes") private String notes = null; @XStreamAsAttribute private String version = MainApp.VERSION; // Used in the XML output // We don't want to save the "Song", with all it's pages, etc... just the song.xml file name public transient final List<Song> songs = Collections.synchronizedList(new ArrayList<Song>()); // Some of the songs in this playlist have been found at a different location protected transient boolean movedSongs = false; // Some of the songs in this playlist could not be found protected transient boolean missingSongs = false; public static final String PROP_NAME = "nameProp"; public static final String PROP_TAGS = "tagsProp"; public static final String PROP_LOADED = "loadedProp"; public static final String PROP_SONG_ADDED = "songAddedProp"; public static final String PROP_SONG_REMOVED = "songRemovedProp"; private transient PropertyChangeSupportUnique pcs; private transient final Object pcsMutex = new Object(); // Could change this to EventListenerList if we had more than 1 event type private transient Set<ChangeListener> listeners; // When separate threads are used to load the playlist songs, isFullyLoaded indicates // the thread has finished loading all the songs. private transient boolean fullyLoaded = true; private transient File sourceFile = null; public static enum Type { Default, AllSongs, Normal } public transient Type type; private static final Icon ICON = ImageUtilities.loadImageIcon( "com/ebixio/virtmus/resources/PlayListNode.png", false); private transient PlayListSavable savable; private transient static Transformer plXFormer; static { InputStream plXform = Song.class.getResourceAsStream("/com/ebixio/virtmus/xml/PlayListTransform.xsl"); TransformerFactory factory = TransformerFactory.newInstance(); try { plXFormer = factory.newTransformer(new StreamSource(plXform)); plXFormer.setOutputProperty(OutputKeys.INDENT, "yes"); plXFormer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); } catch (TransformerConfigurationException ex) { Exceptions.printStackTrace(ex); } } /** Creates a new instance of PlayList. * This constructor is NOT called when the object is deserialized. */ public PlayList() { readResolve(); } /** Creates a new PlayList. * This constructor is NOT called when the object is deserialized. * @param name User visible name for this PlayList. */ public PlayList(String name) { readResolve(); this.name = name; } /** This function is executed by the XStream library after an object is * deserialized. It needs to initialize the transient fields (which are not * serialized/deserialized). */ private Object readResolve() { savable = null; pcs = new PropertyChangeSupportUnique(this); listeners = Collections.synchronizedSet(new HashSet<ChangeListener>()); type = Type.Normal; version = MainApp.VERSION; return this; } public void addAllSongs(File dir, boolean removeExisting) { if (removeExisting) songs.clear(); // It can take a very long time to find all the songs (depending on the // size of the directory tree) so we use a thread. addAllSongsThread t = new addAllSongsThread(); t.dir = dir; t.setName("addAllSongsThread"); t.setPriority(Thread.MIN_PRIORITY); setFullyLoaded(false); t.start(); // Will set isFullyLoaded to true when finished } private class addAllSongsThread extends Thread { public File dir; @Override public void run() { if (!(dir.exists() && dir.isDirectory())) { setFullyLoaded(true); notifyListeners(); return; } FilenameFilter filter = new FilenameFilter() { @Override public boolean accept(File dir, String name) { return (name != null) ? name.endsWith(".song.xml") : false; } }; for (File f: Utils.listFiles(dir, filter, true)) { if (f.canRead()) { Song s = Song.deserialize(f); if (s != null) { songs.add(s); if (type != Type.Normal) sortSongsByName(); notifyListeners(); fire(PROP_SONG_ADDED, null, s); } } } // Compute some simple stats (what kind of music pages are being used) HashMap<String, Integer> hm = new HashMap<>(); synchronized(songs) { for (Song s: songs) { for (MusicPage mp: s.pageOrder) { String ext = Utils.getFileExtension(mp.imgSrc.sourceFile).toLowerCase(); if (mp instanceof MusicPageSVG) { MusicPageSVG svg = (MusicPageSVG)mp; if (svg.hasAnnotations()) { ext += "+svg"; } } if (hm.containsKey(ext)) { hm.put(ext, hm.get(ext) + 1); } else { hm.put(ext, 1); } } } } // Log the page stats LogRecord rec = new LogRecord(Level.INFO, "VirtMus Songs"); Object[] params = new Object[1 + hm.size()]; params[0] = "Songs: " + songs.size(); int idx = 1; for (String k: hm.keySet()) { params[idx++] = k + ": " + hm.get(k); } rec.setParameters(params); StatsLogger.getLogger().log(rec); setFullyLoaded(true); notifyListeners(); MainApp.setStatusText("Loaded all songs from " + dir.getPath()); } } public void sortSongsByName() { synchronized (songs) { // class Comparer implements Comparator { // public int compare(Object song1, Object song2) // { // String n1 = ((Song)song1).getName(); // String n2 = ((Song)song2).getName(); // return n1.compareTo(n2); // } // } // Collections.sort(songs, new Comparer()); Collections.sort(songs); } } public void saveAll() { synchronized(songs) { for (Song s: songs) { if (s.isDirty()) s.save(); } } if (this.isDirty()) save(); } public boolean save() { if (type == Type.Normal) { return serialize(); } else { return false; } } public boolean saveAs() { final Frame mainWindow = WindowManager.getDefault().getMainWindow(); final JFileChooser fc = new JFileChooser(); String playlistDir = NbPreferences.forModule(MainApp.class).get(Options.OptPlayListDir, ""); File pD = new File(playlistDir); if (pD.exists()) { fc.setCurrentDirectory(pD); } fc.addChoosableFileFilter(new PlayListFilter()); int returnVal = fc.showSaveDialog(mainWindow); if (returnVal == JFileChooser.APPROVE_OPTION) { File file = fc.getSelectedFile(); if (! file.toString().endsWith(".playlist.xml")) { file = new File(file.toString().concat(".playlist.xml")); } if (file.exists()) { returnVal = JOptionPane.showConfirmDialog(null, "Overwrite existing file?", "Overwrite?", JOptionPane.YES_NO_OPTION); if (returnVal != JOptionPane.YES_OPTION) { return false; } } this.sourceFile = file; return serialize(); } else { return false; } } public static PlayList open() { final Frame mainWindow = WindowManager.getDefault().getMainWindow(); final JFileChooser fc = new JFileChooser(); fc.setFileSelectionMode(JFileChooser.FILES_ONLY); fc.setMultiSelectionEnabled(false); String playlistDir = NbPreferences.forModule(MainApp.class).get(Options.OptPlayListDir, ""); File pD = new File(playlistDir); if (pD.exists()) { fc.setCurrentDirectory(pD); } fc.addChoosableFileFilter(new PlayListFilter()); int returnVal = fc.showOpenDialog(mainWindow); if (returnVal == JFileChooser.APPROVE_OPTION) { return PlayList.deserialize( fc.getSelectedFile() ); } return null; } public boolean serialize() { return serialize(this.sourceFile); } public boolean serialize(File toFile) { if (toFile == null || toFile.isDirectory()) return false; version = MainApp.VERSION; XStream xs = new XStream(); xs.processAnnotations(PlayList.class); // We need to re-create the songFiles songFiles.clear(); synchronized (songs) { for (Song s: songs) { if (s.getSourceFile() != null) songFiles.add(s.getSourceFile()); } } try { TraxSource traxSource = new TraxSource(this, xs); OutputStreamWriter buffer = new OutputStreamWriter(new FileOutputStream(toFile), "UTF-8"); synchronized(PlayList.class) { plXFormer.transform(traxSource, new StreamResult(buffer)); } //xs.toXML(this, new OutputStreamWriter(new FileOutputStream(toFile), "UTF-8")); } catch (FileNotFoundException ex) { Log.log(ex); return false; } catch (UnsupportedEncodingException | TransformerException ex) { Log.log(ex); return false; } setDirty(false); return true; } public static PlayList deserialize(final File f) { return deserialize(f, null); } public static PlayList deserialize(final File f, PropertyChangeListener listener) { if (f == null || !f.getName().endsWith(".playlist.xml")) return null; XStream xs = new XStream(new PureJavaReflectionProvider()); xs.processAnnotations(PlayList.class); final PlayList pl; FileInputStream fis = null; try { fis = new FileInputStream(f); pl = (PlayList) xs.fromXML(new InputStreamReader(fis, "UTF-8")); } catch (FileNotFoundException ex) { Log.log(ex); NotifyUtil.error("Playlist file not found", f.toString(), ex); return null; } catch (UnsupportedEncodingException ex) { Log.log(ex); NotifyUtil.error("Failed to deserialize", f.toString(), ex); return null; } finally { if (fis != null) try { fis.close(); } catch (IOException ex) { Exceptions.printStackTrace(ex); } } pl.sourceFile = f; pl.setFullyLoaded(false); if (listener != null) { pl.addPropertyChangeListener(PROP_LOADED, listener); } Thread t = new Thread() { @Override public void run() { pl.missingSongs = false; pl.movedSongs = false; for (File sf: pl.songFiles) { if (!sf.exists()) { // See if we can find where it moved String msg = "Playlist " + pl.sourceFile.getAbsolutePath() + " is missing song " + sf.getAbsolutePath() + "."; sf = Utils.findFileRelative(f, sf); if (sf != null && sf.exists()) { msg += " Using " + sf.getAbsolutePath() + " instead."; pl.movedSongs = true; //pl.setDirty(true); } else { msg += " No replacement found."; pl.missingSongs = true; } Log.log(Level.WARNING, msg); } Song s = Song.deserialize(sf); if (s != null) { pl.songs.add(s); } } if (pl.missingSongs) { NotifyUtil.info(f.toString(), "Some songs are missing." + " See the log file (menu View->IDE log) for details."); } pl.setFullyLoaded(true); pl.notifyListeners(); } }; t.setName("Deserialize songs for PlayList: " + pl.getName()); t.start(); return pl; } public void addSong(Song song) { addSong(song, -1); } public void addSong(Song song, int idx) { if (this.type == Type.AllSongs) { if (songs.contains(song)) return; // No firing of property change songs.add(song); sortSongsByName(); } else { if (idx < 0 || idx > songs.size()) idx = songs.size(); songs.add(idx, song); setDirty(true); PlayList all = PlayListSet.findInstance().getPlayList(PlayList.Type.AllSongs); if (all != null) all.addSong(song); } fire(PROP_SONG_ADDED, null, song); } public boolean removeSong(Song song) { boolean result; result = songs.remove(song); if (result) { setDirty(true); fire(PROP_SONG_REMOVED, song, null); } return result; } public void reorder(int[] order) { synchronized(songs) { Song[] ss = new Song[order.length]; for (int i = 0; i < order.length; i++) { ss[order[i]] = songs.get(i); } songs.clear(); songs.addAll(Arrays.asList(ss)); } setDirty(true); notifyListeners(); } public int getSongCnt() { return songs.size(); } public File getSourceFile() { return sourceFile; } public void setSourceFile(File sourceFile) { this.sourceFile = sourceFile; } public String getName() { if (name != null) return name; if (this.sourceFile != null) return this.sourceFile.getName().replaceFirst("\\.playlist\\.xml", ""); return "No name"; } public void setName(String name) { if (type != PlayList.Type.Normal) return; if (!Util.isDifferent(this.name, name)) return; String oldName = this.name; this.name = name; fire(PROP_NAME, oldName, name); setDirty(true); } public void setTags(String tags) { if (type != PlayList.Type.Normal) return; if (!Util.isDifferent(this.tags, tags)) return; String oldTags = this.tags; this.tags = tags; fire(PROP_TAGS, oldTags, tags); setDirty(true); } public String getTags() { return tags; } public void setNotes(String notes) { if (type != PlayList.Type.Normal) return; if (!Util.isDifferent(this.notes, notes)) return; this.notes = notes; setDirty(true); } public String getNotes() { return notes; } public boolean isDirty() { return type == Type.Normal ? savable != null : false; } public void setDirty(boolean isDirty) { if (type != Type.Normal) return; if (isDirty) { if (savable == null) { savable = new PlayListSavable(this); VirtMusLookup.getInstance().add(savable); notifyListeners(); } } else { if (savable != null) { savable.saved(); savable = null; notifyListeners(); } } } /** The PlayList contents do not match the disk contents. * @return true if some of the PlayList files are missing, or have been moved. */ public boolean isStale() { return movedSongs || missingSongs; } public boolean isMissingSongs() { return missingSongs; } /** * @return the fullyLoaded */ public synchronized boolean isFullyLoaded() { return fullyLoaded; } /** * @param fullyLoaded the fullyLoaded to set */ public synchronized void setFullyLoaded(boolean fullyLoaded) { boolean oldFullyLoaded = this.fullyLoaded; this.fullyLoaded = fullyLoaded; pcs.firePropertyChange(PROP_LOADED, oldFullyLoaded, fullyLoaded); } // <editor-fold defaultstate="collapsed" desc=" Listeners "> public void addPropertyChangeListener (PropertyChangeListener pcl) { synchronized(pcsMutex) { pcs.addPropertyChangeListener(pcl); } } public void addPropertyChangeListener(String propertyName, PropertyChangeListener pcl) { synchronized(pcsMutex) { pcs.addPropertyChangeListener(propertyName, pcl); } } public void removePropertyChangeListener(PropertyChangeListener pcl) { synchronized(pcsMutex) { pcs.removePropertyChangeListener(pcl); } } private void fire(String propertyName, Object old, Object nue) { synchronized(pcsMutex) { pcs.firePropertyChange(propertyName, old, nue); } } public void addChangeListener(ChangeListener listener) { if (!listeners.contains(listener)) listeners.add(listener); } public void removeChangeListener(ChangeListener listener) { listeners.remove(listener); } public void notifyListeners() { //Log.log("PlayList::notifyListeners thread: " + Thread.currentThread().getName()); //Log.log("PlayList::notifyListeners: " + this.toString() + " " + getName()); ChangeEvent ev = new ChangeEvent(this); ChangeListener[] cls = listeners.toArray(new ChangeListener[0]); for (ChangeListener cl: cls) { cl.stateChanged(ev); } } // </editor-fold> /** * Implements Comparable * Sorts the PlayList first by type and then by name. * @param other Another PlayList to compare to. * @return -1, 0, 1 */ @Override public int compareTo(PlayList other) { int typeComp = type.compareTo(other.type); if (typeComp != 0) { return typeComp; } else { return getName().compareTo(other.getName()); } } @Override public String toString() { if (type == PlayList.Type.Normal) { return super.toString() + " (" + getName() + ") [" + getSourceFile().getAbsolutePath() + "]"; } else { return super.toString() + " (" + getName() + ")"; } } private class PlayListSavable extends AbstractSavable implements Icon, SaveAsCapable { private final PlayList pl; public PlayListSavable(PlayList pl) { if (pl == null) { throw new IllegalArgumentException("Null PlayList not allowed"); } this.pl = pl; register(); } @Override protected String findDisplayName() { return pl.getName(); } @Override protected void handleSave() throws IOException { pl.save(); VirtMusLookup.getInstance().remove(this); } /** * */ public void saved() { unregister(); VirtMusLookup.getInstance().remove(this); } @Override public boolean equals(Object other) { if (other instanceof PlayListSavable) { PlayListSavable pls = (PlayListSavable) other; return pl.equals(pls.pl); } return false; } @Override public int hashCode() { return pl.hashCode(); } @Override public void paintIcon(Component c, Graphics g, int x, int y) { ICON.paintIcon(c, g, x, y); } @Override public int getIconWidth() { return ICON.getIconWidth(); } @Override public int getIconHeight() { return ICON.getIconHeight(); } @Override public void saveAs(FileObject folder, String fileName) throws IOException { FileObject newFile = folder.getFileObject(fileName); pl.sourceFile = new File(newFile.getNameExt()); save(); } } }