public class CurrentList {

	private static final Logger LOGGER = Logger.getLogger( CurrentList.class.getName() );
	
	public enum Mode {
		ARTIST,
		ALBUM,
		ALBUM_REORDERED,
		PLAYLIST,
		PLAYLIST_UNSAVED,
		EMPTY;
	}
	
	public enum DefaultShuffleMode {
		SEQUENTIAL, SHUFFLE, NO_CHANGE
	}
	
	public enum DefaultRepeatMode {
		PLAY_ONCE, REPEAT, NO_CHANGE
	}
		
	Mode mode = Mode.EMPTY;
	final List <Album> currentAlbums = new ArrayList <Album> ();
	Playlist currentPlaylist;
	Artist currentArtist;
	
	private final ObservableList <CurrentListTrack> items = FXCollections.observableArrayList(); 
	private final FilteredList <CurrentListTrack> currentListFiltered = new FilteredList <CurrentListTrack>( items, p -> true );
	private final SortedList <CurrentListTrack> currentListSorted = new SortedList <CurrentListTrack>( currentListFiltered );
	private SortedList <CurrentListTrack> currentListSortedNoFilter;
	
	private final List <CurrentListListener> listeners = new ArrayList<CurrentListListener> ();

	ThrottledTrackFilter currentListTableFilter;
	
	private AudioSystem audioSystem;
	private Queue queue;

	private DefaultShuffleMode albumShuffleMode = DefaultShuffleMode.SEQUENTIAL;
	private DefaultShuffleMode trackShuffleMode = DefaultShuffleMode.NO_CHANGE;
	private DefaultShuffleMode playlistShuffleMode = DefaultShuffleMode.SHUFFLE;

	private DefaultRepeatMode albumRepeatMode = DefaultRepeatMode.PLAY_ONCE;
	private DefaultRepeatMode trackRepeatMode = DefaultRepeatMode.NO_CHANGE;
	private DefaultRepeatMode playlistRepeatMode = DefaultRepeatMode.REPEAT;
	
	List <Thread> noLoadThreads = new ArrayList <Thread> ();

	transient private boolean hasUnsavedData = true;
	
	private boolean allowAlbumReload = true; //Allow the album to be reloaded if the disk is changed
		//usually enabled, but disabled for the current list after restarting hypnos
	
	public CurrentList ( AudioSystem audioSystem, Queue queue ) {
		this.queue = queue;
		this.audioSystem = audioSystem;

		currentListTableFilter = new ThrottledTrackFilter ( currentListFiltered );
		
		startListWatcher();
		
		items.addListener( (ListChangeListener.Change<? extends Track> change) -> {
			hasUnsavedData = true;			
		});
	}
	
	public boolean hasUnsavedData() {
		return hasUnsavedData;
	}
	
	public void setHasUnsavedData( boolean b ) {
		hasUnsavedData = b;
	}
	
	public boolean allowAlbumReload () {
		return allowAlbumReload;
	}
	
	public void addNoLoadThread ( Thread t ) {
		noLoadThreads.add ( t );
	}
	
	private boolean onBadThread() {
		for ( Thread t : noLoadThreads ) {
			if ( t == Thread.currentThread() ) {
				return true;
			}
		}
		return false;
	}
	
	public void doThreadAware( Runnable runMe ) {
		if ( onBadThread() ) {
			Thread loaderThread = new Thread ( runMe );
			loaderThread.setName( "Short Off-FX Thread" );
			loaderThread.setDaemon( true );
			loaderThread.start();
			
		} else {
			runMe.run();
			
		}
	}
	
	private void startListWatcher() {
		Thread watcher = new Thread( () -> {
			while ( true ) {
				try {
					for ( CurrentListTrack track : items ) {
						boolean isMissing = !Utils.isMusicFile( track.getPath() );
						
						if ( !isMissing && track.isMissingFile() ) {
							track.setIsMissingFile ( false );
							
						} else if ( isMissing && !track.isMissingFile() ) {
							track.setIsMissingFile ( true );
						}
						
						if ( track.needsUpdateFromDisk() ) {
							try {
								track.update();
							} catch ( Exception e ) {
								//No need to log anything, UI should show it
							}
						}
					}

				} catch ( ConcurrentModificationException e ) {
					//If the list is edited, stop what we're doing and start up again later. 
				}
				
				try {
					Thread.sleep ( 250 );
				} catch ( InterruptedException e ) {
					LOGGER.log ( Level.INFO, "Interrupted while sleeping in current list watcher.", e );
				}
			}
		});
		
		watcher.setName( "Current List Watcher" );
		watcher.setDaemon( true );
		watcher.start();
	}
	
	public void addListener ( CurrentListListener listener ) {
		if ( listener == null ) {
			LOGGER.log( Level.INFO, "Null player listener was attempted to be added, ignoring.", new NullPointerException() );
			
		} else if ( listeners.contains( listener ) ) {
			LOGGER.log( Level.INFO, "Duplicate listener was attempted to be added, ignoring.", new Exception() );
			
		} else {			
			listeners.add( listener );
		}
	}
	
	public ObservableList<CurrentListTrack> getItems() {
		return items;
	}
	
	
	public void setMode ( Mode mode ) {
		this.mode = mode;
		notifyListenersStateChanged();
	}
		
	public CurrentListState getState () {
		return new CurrentListState ( new ArrayList<CurrentListTrack>( items ), currentArtist, currentAlbums, currentPlaylist, mode );
	}
	
	public void setDefaultAlbumShuffleMode ( DefaultShuffleMode mode ) {
		this.albumShuffleMode = mode;
	}
	
	public void setDefaultTrackShuffleMode ( DefaultShuffleMode mode ) {
		this.trackShuffleMode = mode;
	}
	
	public void setDefaultPlaylistShuffleMode ( DefaultShuffleMode mode ) {
		this.playlistShuffleMode = mode;
	}
	
	public void setDefaultAlbumRepeatMode ( DefaultRepeatMode mode ) {
		this.albumRepeatMode = mode;
	}
	
	public void setDefaultTrackRepeatMode ( DefaultRepeatMode mode ) {
		this.trackRepeatMode = mode;
	}
	
	public void setDefaultPlaylistRepeatMode ( DefaultRepeatMode mode ) {
		this.playlistRepeatMode = mode;
	}
	
	public DefaultShuffleMode getDefaultTrackShuffleMode () {
		return trackShuffleMode;
	}
	
	public DefaultShuffleMode getDefaultAlbumShuffleMode () {
		return albumShuffleMode;
	}
	
	public DefaultShuffleMode getDefaultPlaylistShuffleMode () {
		return playlistShuffleMode;
	}
	
	public DefaultRepeatMode getDefaultTrackRepeatMode () {
		return trackRepeatMode;
	}
	
	public DefaultRepeatMode getDefaultAlbumRepeatMode () {
		return albumRepeatMode;
	}
	
	public DefaultRepeatMode getDefaultPlaylistRepeatMode () {
		return playlistRepeatMode;
	}
	
	public void setState( CurrentListState state ) {
		items.clear();
		items.addAll( state.getItems() );
		
		currentAlbums.clear();
		currentAlbums.addAll( state.getAlbums() );
		
		currentPlaylist = state.getPlaylist();
		currentArtist = state.getArtist();
		
		mode = state.getMode();
		
		notifyListenersStateChanged();

		allowAlbumReload = false;
		
	}
	
	public Playlist getCurrentPlaylist () {
		return currentPlaylist;
	}
	
	public void shuffleList () {
		Collections.shuffle( items );
		listReordered();
	}
	
	public void shuffleItems ( List<Integer> input ) {
		List<CurrentListTrack> shuffleMe = new ArrayList<CurrentListTrack> ();
		List<Integer> itemIndices = new ArrayList<Integer> ( input );
		
		for ( int index : itemIndices ) {
			shuffleMe.add( items.get( index ) );
		}
		
		Collections.shuffle( shuffleMe );
		
		for ( int index : itemIndices ) {
			items.set( index, shuffleMe.remove( 0 ) );
		}

		listReordered();
	}
		
	public void clearList() {
		if ( items.size() > 0 ) {
			listCleared();
		}
		items.clear();
	}
	
	public void removeTracksAtIndices ( List <Integer> indices ) {

		Runnable runMe = new Runnable() {
			public void run() {
				
				List <Integer> indicesToBeRemoved = new ArrayList <>();
				
				for ( int index : indices ) { 
					indicesToBeRemoved.add( currentListFiltered.getSourceIndex( 
						currentListSorted.getSourceIndex( index ) ) );
				}
				
				indicesToBeRemoved.sort( Comparator.reverseOrder() );
				
				boolean changed = false;
				for ( Integer index : indicesToBeRemoved ) {
					CurrentListTrack removed = items.remove( index.intValue() );
					
					if ( !changed && removed != null ) changed = true;
				}
								
				if ( changed ) {
					if ( items.size() == 0 ) {
						listCleared();
						currentPlaylist = null;
					} else {
						tracksRemoved();
					}
				}
			}
		};
		
		doThreadAware ( runMe );
	}
	
	public void moveTracks ( List<Integer> fromLocations, int toLocation ) {
		if ( fromLocations == null ) {
			LOGGER.log( Level.INFO, "Recieved a null list, ignoring request.", new NullPointerException() );
			return;
		}
		
		if ( fromLocations.size() == 0 ) {
			LOGGER.log( Level.INFO, "Recieved an empty list, ignoring request.", new Exception() );
			return;
		}
				
		ArrayList <CurrentListTrack> tracksToMove = new ArrayList <CurrentListTrack> ( fromLocations.size() );
		
		for ( int index : fromLocations ) {
			if ( index >= 0 && index < items.size() ) {
				tracksToMove.add( items.get( index ) );
			}
		}
		
		for ( int k = fromLocations.size() - 1; k >= 0; k-- ) {
			int index = fromLocations.get( k ).intValue();
			if ( index >= 0 && index < items.size() ) {
				items.remove ( index );
			}
		}

		if ( tracksToMove.size() > 0 ) {
			if ( toLocation > items.size() ) toLocation = items.size();
			items.addAll( toLocation, tracksToMove );
			listReordered();
		}
	}
	
	public void setTrack ( String location ) {
		setTracksPathList ( Arrays.asList( Paths.get( location ) ) );
	}
	
	public void setTrack ( Path path ) {
		setTracksPathList ( Arrays.asList( path ) );
	}

	public void setTrack ( Track track ) {
		setTracksPathList ( Arrays.asList( track.getPath() ) );
	}

	public void setTracks ( List <? extends Track> tracks ) {
		setTracks ( tracks, true );
	}
		
	public void setTracks ( List <? extends Track> tracks, boolean clearQueue ) {
		clearList();
		if ( clearQueue ) queue.clear();
		this.currentListTableFilter.setFilter( "", false );
		audioSystem.getUI().setCurrentListFilterText( "" );
		appendTracks ( tracks );
	}
		
	public void setTracksPathList ( List <Path> paths ) {
		setTracksPathList ( paths, null );
	}
	
	public void setTracksPathList ( List <Path> paths, Runnable afterLoad ) {
		clearList();
		queue.clear();
		appendTracksPathList ( paths, afterLoad );
	}
	
	public void appendTrack ( String location ) {
		appendTracksPathList ( Arrays.asList( Paths.get( location ) ) );
	}
	
	public void appendTrack ( Path path ) {
		appendTracksPathList ( Arrays.asList( path ) );
	}

	public void appendTrack ( Track track ) {
		insertTracks ( items.size(), Arrays.asList( track ) );
	}
	
	public void appendTracks ( List <? extends Track> tracks ) {
		insertTracks ( items.size(), tracks );
	}
		
	public void appendTracksPathList ( List <Path> paths ) {
		insertTrackPathList ( items.size(), paths, null );
	}
	
	public void appendTracksPathList ( List <Path> paths, Runnable afterLoad ) {
		insertTrackPathList ( items.size(), paths, afterLoad );
	}
		
	
	public void insertTracks ( int index, List<? extends Track> tracks ) {
		if ( tracks == null || tracks.size() <= 0 ) {
			LOGGER.log( Level.INFO, "Recieved a null or empty track list. No tracks loaded.", new Exception() );
			return;
		}
		
		boolean startedEmpty = items.isEmpty();
		
		List <CurrentListTrack> addMe = new ArrayList <CurrentListTrack> ( tracks.size() );
		
		for ( Track track : tracks ) {
			addMe.add( new CurrentListTrack ( track ) );
		}

		int targetIndex = index;
		synchronized ( items ) {
			if ( index < 0 ) {
				LOGGER.log( Level.INFO, "Asked to insert tracks at: " + index + ", inserting at 0 instead.", new Exception() );
				targetIndex = 0;
			} else if ( index > items.size() ) {
				LOGGER.log( Level.INFO, "Asked to insert tracks past the end of current list. Inserting at end instead.", new Exception() );
				targetIndex = items.size();
			}
		}

		int tracksAdded = 0;
		for ( CurrentListTrack track : addMe ) {
			addItem ( targetIndex, track );
			targetIndex++;
			tracksAdded++;
		}
		
		if ( tracksAdded > 0 ) {
			if ( startedEmpty ) {
				tracksSet();
			} else {
				tracksAdded();
			}
		}
	}
	
	public void insertTrackPathList ( int index, List<Path> paths ) {
		insertTrackPathList ( index, paths, null );
	}
	
	public void insertTrackPathList ( int index, List <Path> paths, Runnable doAfterLoad ) {
		
		Runnable runMe = new Runnable() {
			public void run() {

				boolean startedEmpty = items.isEmpty();
				
				if ( paths == null || paths.size() <= 0 ) {
					LOGGER.log( Level.INFO, "Recieved a null or empty track list. No tracks loaded.", new NullPointerException() );
					return;
				}
				
				int targetIndex = index;
				synchronized ( items ) {
					if ( index < 0 ) {
						LOGGER.log( Level.INFO, "Asked to insert tracks at: " + index + ", inserting at 0 instead.", new Exception() );
						targetIndex = 0;
					} else if ( index > items.size() ) {
						LOGGER.log( Level.INFO, "Asked to insert tracks past the end of current list. Inserting at end instead.", new Exception()  );
						targetIndex = items.size() - 1;
					}
				}
				
				int tracksAdded = 0;
				for ( Path path : paths ) {
					try {
						addItem ( targetIndex, new CurrentListTrack ( path ) );
						targetIndex++;
						tracksAdded++;
					} catch ( Exception e ) {
						LOGGER.log( Level.INFO, "Couldn't load track: " + path.toString() + ". Skipping.", e );
					}
				}
				
				if ( tracksAdded > 0 ) {
					if ( startedEmpty ) {
						tracksSet();
					} else {
						tracksAdded();
					}
				}
				//TODO: This is really bad practice, but it works for now. Refactor
				//This fixes two problems -- red rows in current list not being white after deleting and D&D
				//and the table not refreshing after drag & drop of folder
				//TODO: Is this still a problem or did new library fix it? Hypnos.getUI().refreshCurrentList();
				
				if ( doAfterLoad != null ) {
					doAfterLoad.run();
				}
			
			}
			
		};
		
		doThreadAware ( runMe );
	}
	
	private void addItem ( int index, CurrentListTrack track ) {
		if ( track == null ) return;
		
		synchronized ( items ) {
			int targetIndex = index;
			if ( index < 0 ) {
				LOGGER.log( Level.INFO, "Asked to insert tracks at: " + index + ", inserting at 0 instead.", new Exception() );
				targetIndex = 0;
			} else if ( index > items.size() ) {
				LOGGER.log( Level.INFO, "Asked to insert tracks past the end of current list. Inserting at end instead.", new Exception() );
				targetIndex = items.size() - 1;
			}
			items.add( targetIndex, track );
		}
	}
	
	public void appendAlbum ( Album album ) {
		appendAlbums ( Arrays.asList( album ) );
	}
	
	public void appendAlbums ( List<Album> albums ) {
		insertAlbums ( items.size(), albums );
	} 
	
	public void insertAlbums ( final int index, List<Album> albums ) {
		Runnable runMe = new Runnable() {
			public void run() {
				int targetIndex = index > items.size() ? items.size() : index;
				
				boolean insertedAtEnd = ( targetIndex == items.size() );
		
				boolean startedEmpty = false;
				Mode startMode = mode;
				
				if ( items.size() == 0 ) startedEmpty = true;
				
				List<Track> addMe = new ArrayList<Track> ();
				for ( Album album : albums ) {
					if ( album != null ) {
						addMe.addAll( album.getTracks() );
					}
				}
				
				insertTracks ( targetIndex, addMe );
				
				if ( startedEmpty ) {
					albumsSet ( albums );
					
				} else if ( startMode == Mode.ALBUM || startMode == Mode.ALBUM_REORDERED ) {
					boolean startedReordered = ( startMode == Mode.ALBUM_REORDERED );
					
					List<Album> fullAlbumList = new ArrayList<Album> ( currentAlbums );
					fullAlbumList.addAll ( albums );
					albumsSet ( fullAlbumList );
					
					if ( startedReordered && mode == Mode.ALBUM ) {
						mode = Mode.ALBUM_REORDERED;
					}
					
					if ( targetIndex != 0 && !insertedAtEnd ) {
						listReordered();
					}
				}
			}
		};
		
		doThreadAware ( runMe );
	}
	
	public void setAlbum ( Album album, boolean clearQueue ) {
		setAlbums ( Arrays.asList( album ), clearQueue );
	}
	
	public void setAlbum ( Album album ) {
		setAlbums ( Arrays.asList( album ) );
	}
	
	public void setAlbums ( List<Album> albums ) {
		setAlbums ( albums, true );
	}

	public void setAlbums ( List<Album> albums, boolean clearQueue ) {
		List <Track> addMe = new ArrayList <Track> ();
		
		List <Album> missing = new ArrayList <Album> ();
		
		for ( Album album : albums ) {
			if ( album == null ) {
				continue;
				
			} else if ( !Files.isDirectory ( album.getPath() ) ) {
				missing.add( album );
				
			} else {
				addMe.addAll ( album.getTracks() );
			}
		}
		
		setTracks ( addMe, clearQueue );
		albumsSet ( albums );
		
		if ( missing.size() > 0 ) {
			Hypnos.warnUserAlbumsMissing ( missing );
		}
	}
	
	
	public void appendPlaylist ( Playlist playlist ) {
		boolean setAsPlaylist = false;
		if ( items.size() == 0 ) setAsPlaylist = true;
		
		appendTracks ( playlist.getTracks() );
		
		if ( setAsPlaylist ) {
			currentPlaylist = playlist;
			playlistsSet ( Arrays.asList( playlist ) );
		}
	}
	
	public void appendPlaylists ( List<Playlist> playlists ) {
		
		
		boolean startedEmpty = false;
		if ( items.size() == 0 ) startedEmpty = true;
		
		int playlistsAdded = 0;
		Playlist lastPlaylist = null;
		for ( Playlist playlist : playlists ) {
			if ( playlist != null ) {
				appendTracks ( playlist.getTracks() );
				lastPlaylist = playlist;
				playlistsAdded++;
			}
		}
		
		if ( startedEmpty && playlistsAdded == 1 ) {
			currentPlaylist = lastPlaylist;
		}
		
		if ( startedEmpty ) {
			playlistsSet ( playlists );
		}
	}
	
	public void insertPlaylists ( int index, List<Playlist> playlists ) {
		Runnable runMe = new Runnable() {
			public void run() {
				int targetIndex = index > items.size() ? items.size() : index;
				
				boolean startedEmpty = false;
				if ( items.size() == 0 ) startedEmpty = true;
				
				List<Track> addMe = new ArrayList<Track> ();
				for ( Playlist playlist : playlists ) {
					if ( playlist != null ) {
						addMe.addAll( playlist.getTracks() );
					}
				}
		
				insertTracks ( targetIndex, addMe );
				
				if ( startedEmpty ) {
					playlistsSet ( playlists );
				} else {
					tracksAdded();
				}
			}
		};
		doThreadAware ( runMe );
	}
	
	public void setPlaylist ( Playlist playlist ) {
		setPlaylists ( Arrays.asList( playlist ) );
	}
	
	public void setPlaylists ( List<Playlist> playlists ) {
		List <Track> addMe = new ArrayList <Track> ();
		
		int playlistsAdded = 0;
		Playlist playlistAdded = null;
		
		for ( Playlist playlist : playlists ) {
			if ( playlist != null ) {
				addMe.addAll ( playlist.getTracks() );
				playlistsAdded++;
				playlistAdded = playlist;
			}
		}
		
		setTracks ( addMe );
		
		if ( playlistsAdded == 1 ) {
			currentPlaylist = playlistAdded;
		}
		
		playlistsSet ( playlists );
	}
	
	public void notifyListenersStateChanged() {

		CurrentListState state = getState();
		for ( CurrentListListener listener : listeners ) {
			listener.stateChanged ( state );
		}

		allowAlbumReload = true;
	}
	
	public void albumsSet ( List <Album> input ) {
		
		if ( input == null ) {
			mode = Mode.EMPTY;
			currentAlbums.clear();
			currentPlaylist = null;
			LOGGER.log( Level.INFO, "Recieved an null album list.", new NullPointerException() );
			notifyListenersStateChanged();
			return;
		}

		ArrayList<Album> albums = new ArrayList<Album> ( input );
		
		albums.removeIf( Objects::isNull );
		
		if ( albums.size() == 0 ) {
			mode = Mode.EMPTY;
			currentAlbums.clear();
			currentPlaylist = null;
			notifyListenersStateChanged();
			return;
		
		} else if ( albums.size() == 1 ) {
			mode = Mode.ALBUM;
			updateDefaultModes();
			currentAlbums.clear();
			currentAlbums.addAll( albums );
			notifyListenersStateChanged();
			return;

		} else {
			boolean initialized = false;
			String simpleTitle = "", year = "", artist = "";
			boolean differentBaseAlbums = false;
			
			for ( int k = 0; k < albums.size(); k++ ) {
				Album album = albums.get( k );
						
				if ( !initialized ) {
					simpleTitle = album.getAlbumTitle();
					year = album.getYear();
					artist = album.getAlbumArtist();
					initialized = true;
					
				} else {
					if ( !album.getAlbumTitle().equals( simpleTitle ) 
					||   !album.getAlbumArtist().equals( artist )
					||   !album.getYear().equals( year )
					){
						differentBaseAlbums = true;
					}
				}
			}
			
			if ( !initialized ) {
				mode = Mode.EMPTY;
				currentAlbums.clear();
				currentPlaylist = null;
				notifyListenersStateChanged();
				return;
				
			} else if ( differentBaseAlbums ) {
				mode = Mode.PLAYLIST_UNSAVED;
				updateDefaultModes();
				currentAlbums.clear();
				currentPlaylist = null;
				notifyListenersStateChanged();
				return;
				
			} else {
				mode = Mode.ALBUM;
				updateDefaultModes();
				currentAlbums.clear();
				currentAlbums.addAll( albums );
				notifyListenersStateChanged();
				return;
			}
		}
	}
	
	//TODO: these nested case statements are ugly
	private void updateDefaultModes() {
		
		DefaultShuffleMode shuffleTarget;
		DefaultRepeatMode repeatTarget;
		
		switch ( mode ) {
			case ALBUM:
			case ALBUM_REORDERED:
				shuffleTarget = albumShuffleMode;
				repeatTarget = albumRepeatMode;
				break;
				
			case PLAYLIST:
				switch ( getCurrentPlaylist().getShuffleMode() ) {
					case SEQUENTIAL: shuffleTarget = DefaultShuffleMode.SEQUENTIAL; break;
					case SHUFFLE: shuffleTarget = DefaultShuffleMode.SHUFFLE; break;
					case USE_DEFAULT: default: shuffleTarget = playlistShuffleMode; break;
				}
				
				switch ( getCurrentPlaylist().getRepeatMode() ) {
					case PLAY_ONCE: repeatTarget = DefaultRepeatMode.PLAY_ONCE; break;
					case REPEAT: repeatTarget = DefaultRepeatMode.REPEAT; break;
					case USE_DEFAULT: default: repeatTarget = playlistRepeatMode; break;
				}
				break;
				
			case PLAYLIST_UNSAVED:
			case EMPTY:
			default:
				shuffleTarget = trackShuffleMode;
				repeatTarget = trackRepeatMode;
				break;
		}
		
		switch ( shuffleTarget ) {
			case NO_CHANGE:
				//Do nothing
				break;
			case SEQUENTIAL:
				audioSystem.setShuffleMode( ShuffleMode.SEQUENTIAL );
				break;
			case SHUFFLE:
				audioSystem.setShuffleMode( ShuffleMode.SHUFFLE );
				break;
		}
		
		switch ( repeatTarget ) {
			case NO_CHANGE:
				//Do nothing
				break;
			case PLAY_ONCE:
				audioSystem.setRepeatMode( RepeatMode.PLAY_ONCE );
				break;
			case REPEAT:
				audioSystem.setRepeatMode( RepeatMode.REPEAT );
				break;
		}
	}
	
	public void playlistsSet ( List <Playlist> playlists ) {
		
		if ( playlists == null ) {
			mode = Mode.EMPTY;
			currentAlbums.clear();
			currentPlaylist = null;
			LOGGER.log( Level.INFO, "Recieved an null playlist list.", new NullPointerException() );
			notifyListenersStateChanged();
			return;
		}
		
		playlists.removeIf( Objects::isNull );
		
		if ( playlists.size() == 0 ) {
			mode = Mode.EMPTY;
			currentAlbums.clear();
			currentPlaylist = null;
			notifyListenersStateChanged();
			return;
		
		} else if ( playlists.size() >= 1 ) {
			mode = Mode.PLAYLIST;
			currentPlaylist = playlists.get( 0 );
			updateDefaultModes();
			currentAlbums.clear();
			notifyListenersStateChanged();
			return;
		}
	}
	
	public void artistSet () {
		mode = Mode.ARTIST;
		updateDefaultModes();
		currentAlbums.clear();
		currentPlaylist = null;
		notifyListenersStateChanged();
	}
	
	public void tracksSet () {
		mode = Mode.PLAYLIST_UNSAVED;
		updateDefaultModes();
		currentAlbums.clear();
		currentPlaylist = null;
		notifyListenersStateChanged();
	}
	
	public void tracksAdded () {
		
		if ( mode == Mode.ARTIST ) {
			mode = Mode.PLAYLIST_UNSAVED;
			
		} else if ( mode == Mode.PLAYLIST ) {
			
			if ( currentPlaylist.getTracks().equals( items ) ) {
				mode = Mode.PLAYLIST;			
				
			} else {
				mode = Mode.PLAYLIST_UNSAVED;
			}
			
		} else if ( mode == Mode.ALBUM || mode == Mode.ALBUM_REORDERED ) {
			mode = Mode.PLAYLIST_UNSAVED;
		}
		
		notifyListenersStateChanged();
		
	}
	
	public void tracksRemoved () {

		if ( mode == Mode.ARTIST ) {
			mode = Mode.PLAYLIST_UNSAVED;
			
		} else if ( mode == Mode.PLAYLIST ) {
			mode = Mode.PLAYLIST_UNSAVED;
			
		} else if ( mode == Mode.ALBUM ) {
			mode = Mode.PLAYLIST_UNSAVED;
			this.currentAlbums.clear();

		}
		
		notifyListenersStateChanged();
	}
	
	public void listCleared () {
		currentAlbums.clear();
		mode = Mode.EMPTY;
		notifyListenersStateChanged();
	}

	public void listReordered () {
		
		if ( mode == Mode.ARTIST ) {
			mode = Mode.PLAYLIST_UNSAVED;
			
		} else if ( mode == Mode.ALBUM ) {
			mode = Mode.ALBUM_REORDERED;
			
		} else if ( mode == Mode.PLAYLIST ) {
			if ( currentPlaylist != null && items.equals( currentPlaylist.getTracks() ) ) {
				mode = Mode.PLAYLIST;			
				
			} else {
				mode = Mode.PLAYLIST_UNSAVED;
			}
		}

		notifyListenersStateChanged();
	}
	
	public void setAndPlayAlbum ( Album album ) {
		setAndPlayAlbums( Arrays.asList( album ) );
	}
	
	public void setAndPlayAlbums ( List <Album> albums ) {
		setAlbums( albums );
		audioSystem.next( false );
		for ( Album album : albums ) {
		  Hypnos.getLibrary().requestRescan ( album.getPath() );
		}
	}
	
	public void setAndPlayPlaylist ( Playlist playlist ) {
		setAndPlayPlaylists( Arrays.asList( playlist ) );
	}

	public void setAndPlayPlaylists ( List <Playlist> playlists ) {
		setPlaylists( playlists );
		audioSystem.next( false );
	}

	public FilteredList <CurrentListTrack> getFilteredItems () {
		return currentListFiltered;
	}
	
	public SortedList <CurrentListTrack> getSortedItems () {
		return currentListSorted;
	}

	public List <CurrentListTrack> getSortedItemsNoFilter () {
		if ( currentListSortedNoFilter == null ) {
			//REFACTOR: This is the right way to do this, but it's really bad in terms of module independence
			//Fix it at some point. 
			currentListSortedNoFilter = new SortedList <CurrentListTrack> ( items );
			currentListSortedNoFilter.comparatorProperty().bind( Hypnos.getUI().getCurrentListPane().currentListTable.comparatorProperty() );
		}
		
		return currentListSortedNoFilter;
	}
	
	public void setItemsToSortedOrder() {
		items.setAll( new ArrayList<CurrentListTrack> ( getSortedItemsNoFilter () ) );
	}

	public void setFilter ( String newValue ) { 
		currentListTableFilter.setFilter( newValue, false );
	}

	public void insertArtists ( int targetIndex, ObservableList <Artist> artists ) {
		List<Track> tracks = new ArrayList<> ();
		for ( Artist artist : artists ) {
			tracks.addAll( artist.getAllTracks() );
		}
		insertTracks ( targetIndex, tracks );
		
	}

	public void setAndPlayArtists ( List <Artist> artists ) {
		List<Track> tracks = new ArrayList<> ();
		for ( Artist artist : artists ) {
			tracks.addAll( artist.getAllTracks() );
		}
		setTracks ( tracks );
		audioSystem.next( false );
		
	}

	public void appendArtists ( List <Artist> artists ) {
		boolean startedEmpty = items.isEmpty();
		for ( Artist artist : artists ) {
			appendTracks ( artist.getAllTracks() );
		}
		
		if ( startedEmpty && artists.size() == 1 && !items.isEmpty() ) {
			this.currentArtist = artists.get( 0 );
			artistSet();
		}
	}

	public void setAndPlayArtist ( Artist artist ) {
		setTracks ( artist.getAllTracks() );
		audioSystem.next( false );
		
		if ( !items.isEmpty() ) {
			this.currentArtist = artist;
			artistSet();
		}
	}

	public void appendArtist ( Artist artist ) {
		boolean startedEmpty = items.isEmpty();
		appendTracks ( artist.getAllTracks() );
		
		if ( startedEmpty && !items.isEmpty() ) {
			this.currentArtist = artist;
			artistSet();
		}
	}

	public void removeDuplicates() {
		Set<CurrentListTrack> nonDuplicateSet = new LinkedHashSet<> ( items );
		
		boolean sameSize = ( items.size() == nonDuplicateSet.size() );
		items.clear( );
		items.addAll( nonDuplicateSet );
		
		if ( !sameSize ) {
			tracksRemoved();
		}
	}
} public enum Mode {
	ARTIST,
	ALBUM,
	ALBUM_REORDERED,
	PLAYLIST,
	PLAYLIST_UNSAVED,
	EMPTY;
} public enum ExitCode {
	NORMAL,
	UNKNOWN_ERROR,
	AUDIO_ERROR, 
	UNSUPPORTED_OS
} public class CurrentListState implements Serializable {
	
	private static final long serialVersionUID = 1L;
	
	private Mode mode;
	private Artist artist;
	private Playlist playlist;
	private List<Album> albums;
	private List<CurrentListTrack> tracks;
	
	public CurrentListState ( List<CurrentListTrack> tracks, Artist artist, List<Album> albums, Playlist playlist, Mode mode ) {
		if ( tracks == null ) {
			this.tracks = null;
		} else {
			this.tracks = new ArrayList<CurrentListTrack> ( tracks );
		}
		
		if ( albums == null ) {
			this.albums = null;
		} else {
			this.albums = new ArrayList<Album> ( albums );
		}
		
		this.artist = artist;
		this.playlist = playlist;
		this.mode = mode;
	}
	
	public Mode getMode() {
		return mode;
	}
	
	public Playlist getPlaylist() {
		return playlist;
	}
	
	public List<Album> getAlbums() {
		return Collections.unmodifiableList( albums );
	}
	
	public Artist getArtist() {
		return artist;
	}
	
	public List<CurrentListTrack> getItems() {
		return Collections.unmodifiableList( tracks );
	}
	
	public String getDisplayString() {

		String retMe = "";
		
		if ( mode == Mode.ARTIST ) {
			retMe = "Artist - " + artist.getName();
		
		} else if ( mode == Mode.ALBUM || mode == Mode.ALBUM_REORDERED ) {
		
			if ( albums.size() == 0 ) {
				
			} else if ( albums.size() == 1 ) {
				Album album = albums.get( 0 );
				retMe = "Album: " + album.getAlbumArtist() + " - " + album.getYear() + " - " + album.getFullAlbumTitle();
				
			} else {
				Album album = albums.get( 0 );
				retMe ="Album: " + album.getAlbumArtist() + " - " + album.getYear() + " - " + album.getAlbumTitle();
			}
			
			if ( mode == Mode.ALBUM_REORDERED ) {
				if ( Hypnos.getOS() == OS.WIN_XP || Hypnos.getOS() == OS.WIN_VISTA || Hypnos.getOS() == OS.WIN_7 ) {
					retMe += " *";
				} else {
					retMe += " 🔀";
				}
			}

		} else if ( mode == Mode.PLAYLIST || mode == Mode.PLAYLIST_UNSAVED ) {
			
			if ( playlist != null ) {
				retMe = "Playlist: " + playlist.getName();
			} else {
				retMe = "Playlist: New";
			}
			
				
			if ( mode == Mode.PLAYLIST_UNSAVED ) {
				retMe += " *";
			}
		} 
		
		return retMe;
	}
} public class CurrentListTrack extends Track {
	@SuppressWarnings("unused")
	private static final Logger LOGGER = Logger.getLogger(CurrentListTrack.class.getName());
	private static final long serialVersionUID = 1L;
	private boolean isCurrentTrack = false;
	private boolean lastCurrentListTrack = false;
	private List<Integer> queueIndex = new ArrayList<Integer>();
	private transient BooleanProperty fileIsMissing = new SimpleBooleanProperty(false);
	private transient ObjectProperty<CurrentListTrackState> displayState = 
			new SimpleObjectProperty<CurrentListTrackState>(new CurrentListTrackState(isCurrentTrack, queueIndex));
	private boolean needsUpdateFromDisk = false;

	public CurrentListTrack(Path source) {
		super(source, false);
	}

	public CurrentListTrack(Track source) {
		super(source);
		needsUpdateFromDisk = true;
	}

	public boolean needsUpdateFromDisk() {
		return needsUpdateFromDisk;
	}

	public void setNeedsUpdateFromDisk(boolean needsUpdate) {
		this.needsUpdateFromDisk = needsUpdate;
	}

	public void update() {
		refreshTagData();
		needsUpdateFromDisk = false;
	}

	public void setIsCurrentTrack(boolean isCurrentTrack) {
		this.isCurrentTrack = isCurrentTrack;
		updateDisplayState();
	}

	public boolean getIsCurrentTrack() {
		return isCurrentTrack;
	}

	public void setIsLastCurrentListTrack(boolean last) {
		this.lastCurrentListTrack = last;
	}

	public boolean isLastCurrentListTrack() {
		return lastCurrentListTrack;
	}

	public List<Integer> getQueueIndices() {
		return queueIndex;
	}

	public void clearQueueIndex() {
		queueIndex.clear();
		updateDisplayState();
	}

	public void addQueueIndex(Integer index) {
		queueIndex.add(index);
		updateDisplayState();
	}

	public BooleanProperty fileIsMissingProperty() {
		return fileIsMissing;
	}

	public boolean isMissingFile() {
		return fileIsMissing.getValue();
	}

	public void setIsMissingFile(boolean missing) {
		fileIsMissing.set(missing);
		if (missing) {
			setIsCurrentTrack(false);
		}
	}

	private void updateDisplayState() {
		displayState.setValue(new CurrentListTrackState(isCurrentTrack, queueIndex));
	}

	public ObjectProperty<CurrentListTrackState> displayStateProperty() {
		return displayState;
	}

	@Override
	public void refreshTagData() {
		super.refreshTagData();
		for (Track track : Hypnos.getLibrary().getTrackData()) {
			if (track.equals(this)) {
				track.refreshTagData();
				if(track.getAlbum() != null) {
					track.getAlbum().updateData();
				}
				this.setAlbum(track.getAlbum());
			}
		}
	}

	private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
		in.defaultReadObject();
		fileIsMissing = new SimpleBooleanProperty(false);
		queueIndex = new ArrayList<Integer>();
		displayState = 
			new SimpleObjectProperty<CurrentListTrackState>(new CurrentListTrackState(isCurrentTrack, queueIndex));
	}
} public class Hypnos extends Application {

	private static final Logger LOGGER = Logger.getLogger( Hypnos.class.getName() );
	
	public enum ExitCode {
		NORMAL,
		UNKNOWN_ERROR,
		AUDIO_ERROR, 
		UNSUPPORTED_OS
	}
	
	public enum OS {
		WIN_XP ( "Windows XP" ),
		WIN_VISTA ( "Windows Vista" ),
		WIN_7 ( "Windows 7" ),
		WIN_8 ( "Windows 8" ),
		WIN_10 ( "Windows 10" ),
		WIN_UNKNOWN ( "Windows Unknown" ),
		OSX ( "Mac OSX" ),
		NIX ( "Linux/Unix" ), 
		UNKNOWN ( "Unknown" );
		
		private String displayName;
		OS ( String displayName ) { this.displayName = displayName; }
		public String getDisplayName () { return displayName; }
		
		public boolean isWindows() {
			switch ( this ) {
				case WIN_10:
				case WIN_7:
				case WIN_8:
				case WIN_UNKNOWN:
				case WIN_VISTA:
				case WIN_XP:
					return true;
				case NIX:
				case OSX:
				case UNKNOWN:
				default:
					return false;
			}
		}
		
		public boolean isOSX() {
			switch ( this ) {
				case OSX:
					return true;
				case WIN_10:
				case WIN_7:
				case WIN_8:
				case WIN_UNKNOWN:
				case WIN_VISTA:
				case WIN_XP:
				case NIX:
				case UNKNOWN:
				default:
					return false;
			}
		}
		
		public boolean isLinux() {
			switch ( this ) {
				case NIX:
					return true;
				case WIN_10:
				case WIN_7:
				case WIN_8:
				case WIN_UNKNOWN:
				case WIN_VISTA:
				case WIN_XP:
				case UNKNOWN:
				case OSX:
				default:
					return false;
			}
		}
	}
	
	private static OS os = OS.UNKNOWN;
	private static String build;
	private static String version;
	private static String buildDate;
	private static Path rootDirectory;
	private static Path configDirectory;
	private static Path logFile, logFileBackup, logFileBackup2;
	private static boolean isStandalone = false;
	private static boolean isDeveloping = false;
	private static boolean disableGlobalHotkeysRequestedByProperties = false;
	
	private static Persister persister;
	private static AudioSystem audioSystem;
	private static FXUI ui;
	private static Library library;
	private static GlobalHotkeys globalHotkeys;
	
	private static PrintStream originalOut;
	private static PrintStream originalErr;
	
	private static LoaderSpeed loaderSpeed = LoaderSpeed.HIGH;
	
	private static ByteArrayOutputStream logBuffer; //Used to store log info until log file is initialized
	
	private static Formatter logFormat = new Formatter() {
		SimpleDateFormat dateFormat = new SimpleDateFormat ( "MMM d, yyyy HH:mm:ss aaa" );
		public String format ( LogRecord record ) {
			
			String exceptionMessage = "";
			
			if ( record.getThrown() != null ) {
				StringWriter sw = new StringWriter();
				record.getThrown().printStackTrace( new PrintWriter( sw ) );
				exceptionMessage = "\n" + sw.toString();
			}
			
			String retMe = dateFormat.format( new Date ( record.getMillis() ) )
				  + " " + record.getLoggerName()
				  + " " + record.getSourceMethodName()
				  + System.lineSeparator()
				  + record.getLevel() + ": " + record.getMessage()
				  + exceptionMessage
				  + System.lineSeparator()
				  + System.lineSeparator();
			
			return retMe;
		}
	};
	
	public static OS getOS() {
		return os;
	}
	
	public static String getVersion() {
		return version;
	}
	
	public static String getBuild() {
		return build;
	}
	
	public static String getBuildDate() {
		return buildDate;
	}
	
	public static boolean isStandalone() {
		return isStandalone;
	}
	
	public static boolean isDeveloping() {
		return isDeveloping;
	}
	
	public static Path getRootDirectory() {
		return rootDirectory;
	}
	
	public static Path getConfigDirectory() {
		return configDirectory;
	}
	
	public static Path getLogFile() {
		return logFile;
	}
	
	public static Path getLogFileBackup() {
		return logFileBackup;
	}
	
	public static Persister getPersister() {
		return persister;
	}
	
	public static Library getLibrary() {
		return library;
	}
	
	public static FXUI getUI() {
		return ui;
	}
	
	public static LoaderSpeed getLoaderSpeed ( ) {
		return loaderSpeed;
	}
	
	public static void setLoaderSpeed ( LoaderSpeed speed ) {
		loaderSpeed = speed;
		ui.setLoaderSpeedDisplay ( speed );
	}
	
	private static void startLogToBuffer() {
		originalOut = System.out;
		originalErr = System.err;
		
		logBuffer = new ByteArrayOutputStream();
		
		System.setOut( new PrintStream ( logBuffer ) );
		System.setErr( new PrintStream ( logBuffer ) );

		Logger.getLogger( "" ).getHandlers()[0].setFormatter( logFormat );
	}
	
	private void parseSystemProperties() {
				
		isStandalone = Boolean.getBoolean( "hypnos.standalone" );
		isDeveloping = Boolean.getBoolean( "hypnos.developing" );
		disableGlobalHotkeysRequestedByProperties = Boolean.getBoolean( "hypnos.disableglobalhotkeys" );
		
		if ( isStandalone ) LOGGER.info ( "Running as standalone - requested by system properties set at program launch" );
		if ( isDeveloping ) LOGGER.info ( "Running on development port - requested by system properties set at program launch" );
		if ( disableGlobalHotkeysRequestedByProperties ) LOGGER.info ( "Global hotkeys disabled - requested by system properties set at program launch" );
	}
	
	private void determineOS() {
		String osString = System.getProperty( "os.name" ).toLowerCase();
		
		if ( osString.indexOf( "win" ) >= 0 ) {
			if ( osString.indexOf( "xp" ) >= 0 ) {
				os = OS.WIN_XP;

			} else if ( osString.indexOf( "vista" ) >= 0 ) {
				os = OS.WIN_VISTA;
			
			} else if ( osString.indexOf( "7" ) >= 0 ) {
				os = OS.WIN_7;
				
			} else if ( osString.indexOf( "8" ) >= 0 ) {
				os = OS.WIN_8;
				
			} else if ( osString.indexOf( "10" ) >= 0 ) {
				os = OS.WIN_10;

			} else {
				os = OS.WIN_UNKNOWN;
			}
			
		} else if ( osString.indexOf( "nix" ) >= 0 || osString.indexOf( "linux" ) >= 0 ) {
			os = OS.NIX;

		} else if ( osString.indexOf( "mac" ) >= 0 ) {
			os = OS.OSX;
			
		} else {
			os = OS.UNKNOWN;
		}
		
		LOGGER.info ( "Operating System: " + os.getDisplayName() );
	}
	
	public String determineVersionInfo () {
		@SuppressWarnings("rawtypes")
		Enumeration resEnum;
		try {
			resEnum = Thread.currentThread().getContextClassLoader().getResources( JarFile.MANIFEST_NAME );
			while ( resEnum.hasMoreElements() ) {
				try {
					URL url = (URL) resEnum.nextElement();
					
					if ( url.getFile().toLowerCase().contains( "hypnos" ) ) {
						try ( 
							InputStream is = url.openStream();
						) {
							if ( is != null ) {
								Manifest manifest = new Manifest( is );
								Attributes mainAttribs = manifest.getMainAttributes();
								if ( mainAttribs.getValue( "Hypnos" ) != null ) {
									version = mainAttribs.getValue( "Implementation-Version" );
									build = mainAttribs.getValue ( "Build-Number" );
									buildDate = mainAttribs.getValue ( "Build-Date" );
									LOGGER.info ( "Version: " + version + ", Build: " + build + ", Build Date: " + buildDate );
								}
							}
						}
					}
				} catch ( Exception e ) {
					// Silently ignore wrong manifests on classpath?
				}
			}
		} catch ( Exception e1 ) {
			// Silently ignore wrong manifests on classpath?
		}
		return null;
	}
	
	
	private void setupRootDirectory () {
		String path = FXUI.class.getProtectionDomain().getCodeSource().getLocation().getPath();
				
		try {
			String decodedPath = URLDecoder.decode(path, "UTF-8");
			decodedPath = decodedPath.replaceFirst("^/(.:/)", "$1");
			rootDirectory = Paths.get( decodedPath ).getParent();
			
		} catch ( UnsupportedEncodingException e ) {
			rootDirectory = Paths.get( path ).getParent();
		}
	}
	
	private void setupConfigDirectory () {
		// PENDING: We might want to make a few fall-throughs if these locations don't exist.
		String home = System.getProperty( "user.home" );

		if ( Hypnos.isStandalone() ) {
			configDirectory = getRootDirectory().resolve( "config" );

		} else {
			final String x = File.separator;
			switch ( getOS() ) {
				case NIX:
					configDirectory = Paths.get( home + x + ".config/hypnos" );
					break;
				case OSX:
					configDirectory = Paths.get( home + x + "Preferences" + x + "Hypnos" );
					break;
				case WIN_10:
					configDirectory = Paths.get( home + x + "AppData" + x + "Local" + x + "Hypnos" );
					break;
				case WIN_7:
					configDirectory = Paths.get( home + x + "AppData" + x + "Local" + x + "Hypnos" );
					break;
				case WIN_8:
					configDirectory = Paths.get( home + x + "AppData" + x + "Local" + x + "Hypnos" );
					break;
				case WIN_UNKNOWN:
					configDirectory = Paths.get( home + x + "AppData" + x + "Local" + x + "Hypnos" );
					break;
				case WIN_VISTA:
					configDirectory = Paths.get( home + x + "AppData" + x + "Local" + x + "Hypnos" );
					break;
				case WIN_XP:
					//Do nothing, windows XP not supported
					//configDirectory = Paths.get( home + x + "Local Settings" + x + "Application Data" + x + "Hypnos" );
					break;
				case UNKNOWN: //Fall through
				default:
					configDirectory = Paths.get( home + x + ".hypnos" );
					break;
			}
		}
		
		File configDirectory = Hypnos.getConfigDirectory().toFile();
		
		if ( !configDirectory.exists() ) {
			LOGGER.info( "Config directory doesn't exist, creating: " + Hypnos.getConfigDirectory() );
			try {
				configDirectory.mkdirs();
			} catch ( Exception e ) {
				String message = "Unable to create config directory, data will not be saved.\n" + Hypnos.getConfigDirectory();
				LOGGER.info( message );
				///TODO: Some version of a deferred ui.notifyUserError( message );
			}
		} else if ( !configDirectory.isDirectory() ) {
			String message = "There is a file where the config directory should be, data will not be saved.\n" + Hypnos.getConfigDirectory();
			LOGGER.info( message );
			///TODO: Some version of a deferred ui.notifyUserError( message );
			
		} else if ( !configDirectory.canWrite() ) {
			String message = "Cannot write to config directory, data will not be saved.\n" + Hypnos.getConfigDirectory();
			LOGGER.info( message );
			///TODO: Some version of a deferred ui.notifyUserError( message );
		}
	}
	
	private void setupLogFile() {
		logFile = configDirectory.resolve( "hypnos.log" );
		logFileBackup = configDirectory.resolve( "hypnos.log.1" );
		logFileBackup2 = configDirectory.resolve( "hypnos.log.2" );
		
		if ( Files.exists( logFileBackup ) ) {
			try {
				Files.move( logFileBackup, logFileBackup2, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );
			} catch ( Exception e ) {
				LOGGER.log ( Level.WARNING, "Unable to create 2nd backup logfile", e );
			}
		}
		
		if ( Files.exists( logFile ) ) {
			try {
				Files.move( logFile, logFileBackup, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );
			} catch ( Exception e ) {
				LOGGER.log ( Level.WARNING, "Unable to create 1st backup logfile", e );
			}
		}
				
		try {
			logFile.toFile().createNewFile();
		} catch ( Exception e ) {
			LOGGER.log ( Level.WARNING, "Unable to create logfile", e );
		}
		
		try {
			PrintWriter logOut = new PrintWriter ( new FileOutputStream ( logFile.toFile(), false ) );
			String logBufferS = logBuffer.toString();
			logOut.print( logBufferS );
			originalErr.print ( logBufferS );
			logOut.close();
			
		} catch ( Exception e ) {
			LOGGER.log ( Level.WARNING, "Unable to write initial log entries to log file", e );
		}
		
		try {
			//Remove the existing console handler
			Logger.getLogger( "" ).removeHandler( Logger.getLogger( "" ).getHandlers()[0] );
						
			//restore system out and system err, but put all of them to the err channel
			System.setOut( originalErr );
			System.setErr( originalErr );
			
			//Add a file handler to output to logFile			
			FileHandler fileHandler = new FileHandler( logFile.toString(), true );     
			fileHandler.setFormatter( logFormat );
	    Logger.getLogger( "" ).addHandler( fileHandler );
			
			//Create console output
			ConsoleHandler consoleHandler = new ConsoleHandler();    
			consoleHandler.setFormatter( logFormat );
	    Logger.getLogger( "" ).addHandler( consoleHandler );
	        
		} catch ( IOException e ) {
			LOGGER.log( Level.WARNING, "Unable to setup file handler for logger.", e );
		}
	}
	
	public static boolean globalHotkeysDisabled() {
		//TODO: ask hotkeys, don't keep a value in Hypnos
		return disableGlobalHotkeysRequestedByProperties;
	}

	public static void doHotkeyAction ( Hotkey hotkey ) {
		Platform.runLater( () -> { //TODO: Should this runLater() be around everything or just show_hide_ui? 
			switch ( hotkey ) {
				case NEXT:
					audioSystem.next();
					break;
				case PLAY:
					audioSystem.play();
					break;
				case PREVIOUS:
					audioSystem.previous();
					break;
				case SHOW_HIDE_UI:
					ui.toggleHidden();
					break;
				case SKIP_BACK:
					long target = audioSystem.getPositionMS() - 5000 ;
					if ( target < 0 ) target = 0;
					audioSystem.seekMS( target ); 
					break;
				case SKIP_FORWARD:
					audioSystem.seekMS( audioSystem.getPositionMS() + 5000 ); 
					break;
				case STOP:
					audioSystem.stop( StopReason.USER_REQUESTED );
					break;
				case TOGGLE_MUTE:
					audioSystem.toggleMute();
					break;
				case PLAY_PAUSE:
					audioSystem.togglePlayPause();
					break;
				case TOGGLE_REPEAT:
					audioSystem.toggleRepeatMode();
					break;
				case TOGGLE_SHUFFLE:
					audioSystem.toggleShuffleMode();
					break;
				case VOLUME_DOWN:
					audioSystem.decrementVolume();
					break;
				case VOLUME_UP:
					audioSystem.incrementVolume();
					break;
				default:
					break;
			}
		});
	}
	
	public static void warnUserPlaylistsNotSaved ( ArrayList <Playlist> errors ) {
		ui.warnUserPlaylistsNotSaved ( errors );
	}

	public static void warnUserAlbumsMissing ( List <Album> missing ) {
		ui.warnUserAlbumsMissing ( missing );
	}
	
	
	private long setTracksLastTime = 0;
	
	public void applyCLICommands ( List <SocketCommand> commands ) {
		ArrayList<Path> tracksToPlay = new ArrayList<Path>();
		for ( SocketCommand command : commands ) {
			if ( command.getType() == SocketCommand.CommandType.SET_TRACKS ) {
				for ( File file : (List<File>) command.getObject() ) {
					if ( file.isDirectory() ) {
						tracksToPlay.addAll( Utils.getAllTracksInDirectory( file.toPath() ) );
						
					} else if ( Utils.isPlaylistFile( file.toPath() ) ) {
						//TODO: It's kind of lame to load the tracks here just to discard them and load them again a few seconds later
						//Maybe modify loadPlaylist so i can just ask for the specified paths, without loading tag data
						Playlist playlist = Playlist.loadPlaylist( file.toPath() );
						for ( Track track : playlist.getTracks() ) {
							tracksToPlay.add( track.getPath() );
						}
						
					} else if ( Utils.isMusicFile( file.toPath() ) ) {
						tracksToPlay.add( file.toPath() );
						
					} else {
						LOGGER.log( Level.INFO, "Recived non-music, non-playlist file, ignoring: " + file, new Exception() );
					}
				}
			}
		}
		
		if ( tracksToPlay.size() > 0 ) {
			if ( System.currentTimeMillis() - setTracksLastTime > 2000 ) {
				Platform.runLater( () -> {
					audioSystem.getCurrentList().setTracksPathList( tracksToPlay,
						new Runnable() {
							@Override
							public void run() {
								audioSystem.next( false );
							}
						}
					);
				});
			} else {
				Platform.runLater( () -> {
					audioSystem.getCurrentList().appendTracksPathList ( tracksToPlay );
				});
			}
			setTracksLastTime = System.currentTimeMillis();
		}

		for ( SocketCommand command : commands ) {
			if ( command.getType() == SocketCommand.CommandType.CONTROL ) {
				int action = (Integer)command.getObject();
				Platform.runLater( () -> {
					switch ( action ) {
						case SocketCommand.NEXT: 
							audioSystem.next();
							break;
						case SocketCommand.PREVIOUS:
							audioSystem.previous();
							break;
						case SocketCommand.PAUSE:
							audioSystem.pause();
							break;
						case SocketCommand.PLAY:
							audioSystem.unpause();
							break;
						case SocketCommand.TOGGLE_PAUSE:
							audioSystem.togglePause();
							break;
						case SocketCommand.STOP:
							audioSystem.stop( StopReason.USER_REQUESTED );
							break;
						case SocketCommand.TOGGLE_MINIMIZED:
							ui.toggleMinimized();
							break;
						case SocketCommand.VOLUME_DOWN:
							audioSystem.decrementVolume();
							break;
						case SocketCommand.VOLUME_UP:
							audioSystem.incrementVolume();
							break;
						case SocketCommand.SEEK_BACK:
							long target = audioSystem.getPositionMS() - 5000 ;
							if ( target < 0 ) target = 0;
							audioSystem.seekMS( target ); 
							break;
						case SocketCommand.SEEK_FORWARD:
							audioSystem.seekMS( audioSystem.getPositionMS() + 5000 ); 
							break;
						case SocketCommand.SHOW:
							ui.restoreWindow();
							break;
					}
				});
			} 
		}
	}

	public static void exit ( ExitCode exitCode ) {
		Platform.runLater( () -> ui.getMainStage().hide() );
		
		if ( globalHotkeys != null ) {
			globalHotkeys.prepareToExit();
		}
		
		ui.getTrayIcon().prepareToExit();
		
		if ( audioSystem != null && ui != null ) {
			EnumMap <Setting, ? extends Object> fromAudioSystem = audioSystem.getSettings();
			EnumMap <Setting, ? extends Object> fromUI = ui.getSettings();
			audioSystem.stop ( StopReason.USER_REQUESTED );
			audioSystem.releaseResources();
			persister.saveAllData( fromAudioSystem, fromUI );
		}
		System.exit ( exitCode.ordinal() );
	}
	
	@Override
	public void stop () {
		exit ( ExitCode.NORMAL );
	}
	
	@Override
	public void start ( Stage stage ) {
		long baseStartTime = System.currentTimeMillis();
		String loadTimeMessage = "Load Time Breakdown\n";
		try {
		
			long thisTaskStart = System.currentTimeMillis();
			startLogToBuffer();
			loadTimeMessage += "- Start Log to Buffer: " + (System.currentTimeMillis() - thisTaskStart + "\n");
			thisTaskStart = System.currentTimeMillis();
			
			thisTaskStart = System.currentTimeMillis();
			parseSystemProperties();
			loadTimeMessage += "- Parse System Properties: " + (System.currentTimeMillis() - thisTaskStart + "\n");
			thisTaskStart = System.currentTimeMillis();
			
			thisTaskStart = System.currentTimeMillis();
			determineOS();
			loadTimeMessage += "- Determine OS " + (System.currentTimeMillis() - thisTaskStart + "\n");
			thisTaskStart = System.currentTimeMillis();
			
			thisTaskStart = System.currentTimeMillis();
			setupRootDirectory(); 
			loadTimeMessage += "- Setup Root Directory: " + (System.currentTimeMillis() - thisTaskStart + "\n");
			thisTaskStart = System.currentTimeMillis();
			
			thisTaskStart = System.currentTimeMillis();
			setupConfigDirectory();
			loadTimeMessage += "- Setup Config Directory: " + (System.currentTimeMillis() - thisTaskStart + "\n");
			thisTaskStart = System.currentTimeMillis();
			
			thisTaskStart = System.currentTimeMillis();
			determineVersionInfo();
			loadTimeMessage += "- Read Version Info: " + (System.currentTimeMillis() - thisTaskStart + "\n");
			thisTaskStart = System.currentTimeMillis();

			thisTaskStart = System.currentTimeMillis();
			String[] args = getParameters().getRaw().toArray ( new String[0] );
			CLIParser parser = new CLIParser( );
			ArrayList <SocketCommand> commands = parser.parseCommands( args );
			loadTimeMessage += "- Parse Args: " + (System.currentTimeMillis() - thisTaskStart + "\n");
			thisTaskStart = System.currentTimeMillis();
			
			thisTaskStart = System.currentTimeMillis();
			SingleInstanceController singleInstanceController = new SingleInstanceController();

			if ( singleInstanceController.isFirstInstance() ) {
				thisTaskStart = System.currentTimeMillis();
				setupLogFile();
				loadTimeMessage += "- Setup Log File: " + (System.currentTimeMillis() - thisTaskStart + "\n");
				thisTaskStart = System.currentTimeMillis();
				
				thisTaskStart = System.currentTimeMillis();
				library = new Library();
				loadTimeMessage += "- Initialize Library: " + (System.currentTimeMillis() - thisTaskStart + "\n");
				thisTaskStart = System.currentTimeMillis();
				
				thisTaskStart = System.currentTimeMillis();
				audioSystem = new AudioSystem();
				loadTimeMessage += "- Initialize Audio System: " + (System.currentTimeMillis() - thisTaskStart + "\n");
				thisTaskStart = System.currentTimeMillis();
				
				thisTaskStart = System.currentTimeMillis();
				globalHotkeys = new GlobalHotkeys( getOS(), disableGlobalHotkeysRequestedByProperties );
				globalHotkeys.addListener( Hypnos::doHotkeyAction );
				loadTimeMessage += "- Initialize Hotkeys: " + (System.currentTimeMillis() - thisTaskStart + "\n");
				thisTaskStart = System.currentTimeMillis();
				
				thisTaskStart = System.currentTimeMillis();
				ui = new FXUI ( stage, library, audioSystem, globalHotkeys );
				audioSystem.setUI ( ui );
				library.setAudioSystem ( audioSystem );
				loadTimeMessage += "- Initialize FX UI: " + (System.currentTimeMillis() - thisTaskStart + "\n");
				thisTaskStart = System.currentTimeMillis();
				
				thisTaskStart = System.currentTimeMillis();
				persister = new Persister ( ui, library, audioSystem, globalHotkeys );
				loadTimeMessage += "- Initialize Persister: " + (System.currentTimeMillis() - thisTaskStart + "\n");
				thisTaskStart = System.currentTimeMillis();
				
				switch ( getOS() ) {
					case NIX:
					case OSX: {
						EnumMap <Setting, String> pendingSettings = persister.loadSettingsFromDisk();
						persister.loadCurrentList();
						ui.applySettingsBeforeWindowShown( pendingSettings );
						
						if ( pendingSettings.containsKey( Setting.LOADER_SPEED ) ) {
							Hypnos.setLoaderSpeed( LoaderSpeed.valueOf( pendingSettings.get( Setting.LOADER_SPEED ) ) );
							pendingSettings.remove( Setting.LOADER_SPEED );
						} else {
							Hypnos.setLoaderSpeed(LoaderSpeed.HIGH);
						}

						ui.setLibraryLabelsToLoading();
						ui.showMainWindow();
						
						Thread finishLoadingThread = new Thread ( () -> {
							Platform.runLater( () -> {
								ui.applySettingsAfterWindowShown( pendingSettings );
								persister.logUnusedSettings ( pendingSettings );
							});

							boolean sourcesLoaded = persister.loadRoots();
							if ( sourcesLoaded ) {
								persister.loadAlbumsAndTracks();
							}
							audioSystem.applySettings ( pendingSettings );
							
							persister.loadQueue();
							audioSystem.linkQueueToCurrentList();
							persister.loadHistory();
							persister.loadPlaylists();
							persister.loadHotkeys();
							
							Platform.runLater( () -> ui.getLibraryPane().updatePlaceholders() );
							
							ui.refreshHotkeyList();
							
							applyCLICommands( commands );
							singleInstanceController.startCLICommandListener ( this );

							library.setUI( ui );
							library.startThreads();
			
							LOGGER.info( "Hypnos finished loading." );
							
							UpdateChecker updater = new UpdateChecker();
							boolean updateAvailable = updater.updateAvailable();
							if ( updateAvailable ) LOGGER.info( "Updates available" );
							ui.setUpdateAvailable ( updateAvailable );
							
							try { Thread.sleep ( 2000 ); } catch ( InterruptedException e ) {}
							
							ui.fixTables();
						} );
						
						finishLoadingThread.setName ( "Hypnos Load Finisher for Nix" );
						finishLoadingThread.setDaemon( false );
						finishLoadingThread.start();
					} 
					break;
					
					case UNKNOWN:
					case WIN_10:
					case WIN_7:
					case WIN_8:
					case WIN_UNKNOWN:
					case WIN_VISTA:{
						thisTaskStart = System.currentTimeMillis();
						EnumMap <Setting, String> pendingSettings = persister.loadSettingsFromDisk();
						loadTimeMessage += "- Read Settings from Disk: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						thisTaskStart = System.currentTimeMillis();
						
						thisTaskStart = System.currentTimeMillis();
						persister.loadCurrentList();
						loadTimeMessage += "- Read Current List from Disk " + (System.currentTimeMillis() - thisTaskStart + "\n");
						thisTaskStart = System.currentTimeMillis();
						
						thisTaskStart = System.currentTimeMillis();
						audioSystem.applySettings ( pendingSettings );
						if ( pendingSettings.containsKey( Setting.LOADER_SPEED ) ) {
							Hypnos.setLoaderSpeed( LoaderSpeed.valueOf( pendingSettings.get( Setting.LOADER_SPEED ) ) );
							pendingSettings.remove( Setting.LOADER_SPEED );
						} else {
							Hypnos.setLoaderSpeed(LoaderSpeed.HIGH);
						}
						ui.applySettingsBeforeWindowShown( pendingSettings );
						ui.applySettingsAfterWindowShown( pendingSettings );
						persister.logUnusedSettings ( pendingSettings );
						loadTimeMessage += "- Apply Settings: " + (System.currentTimeMillis() - thisTaskStart + "\n");

						thisTaskStart = System.currentTimeMillis();
						boolean sourcesLoaded = persister.loadRoots();
						if ( sourcesLoaded ) {
							persister.loadAlbumsAndTracks();
						}
						loadTimeMessage += "- Load Albums and Tracks: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						persister.loadQueue();
						loadTimeMessage += "- Load Queue from Disk: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						audioSystem.linkQueueToCurrentList();
						loadTimeMessage += "- Link Queue to current list: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						persister.loadHistory();
						loadTimeMessage += "- Load History: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						persister.loadPlaylists();
						loadTimeMessage += "- Load Playlists: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						persister.loadHotkeys();
						loadTimeMessage += "- Load Hotkeys: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						applyCLICommands( commands );
						singleInstanceController.startCLICommandListener ( this );
						loadTimeMessage += "- Apply CLI Commands: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						library.setUI( ui );
						library.startThreads();
						persister.startThread();
						loadTimeMessage += "- Start background threads: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						ui.showMainWindow();
						loadTimeMessage += "- Show UI: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						thisTaskStart = System.currentTimeMillis();
						ui.getLibraryPane().updatePlaceholders();
						ui.fixTables();
						ui.settingsWindow.refreshHotkeyFields();
						loadTimeMessage += "- Post show UI fixes: " + (System.currentTimeMillis() - thisTaskStart + "\n");
						
						loadTimeMessage += "Total Load Time: " + (System.currentTimeMillis() - baseStartTime);
						
						LOGGER.info(loadTimeMessage);
						
						
						LOGGER.info( "Hypnos finished loading." );
						UpdateChecker updater = new UpdateChecker();
						if ( updater.updateAvailable() ) {
							LOGGER.info( "Updates available" );
						}
					} 
					break;

					case WIN_XP:
					default: {
						LOGGER.log(Level.SEVERE, "Operating System not supported, exiting.");
						exit(ExitCode.UNSUPPORTED_OS);
					}
				}
				
				
			} else {
				boolean gotResponse = singleInstanceController.sendCommandsThroughSocket( commands );
				if ( commands.size() > 0 ) {
					originalOut.println ( "Commands sent to currently running Hypnos." );
					
				} else if ( gotResponse ) {
					singleInstanceController.sendCommandsThroughSocket( Arrays.asList(
						new SocketCommand ( SocketCommand.CommandType.CONTROL, SocketCommand.SHOW )
					));
					originalOut.println ( "Hypnos is already running, brought to front." );
					
				} else {
					FXUI.notifyUserHypnosNonResponsive();
				}
				
				System.exit ( 0 ); //We don't use Hypnos.exit here intentionally. 
			}
			
		} catch ( Exception e ) {
			LOGGER.log( Level.SEVERE, e.getClass() + ":  Exception caught at top level of Hypnos. Exiting.", e );
			exit ( ExitCode.UNKNOWN_ERROR );
		}
	}

	public static void main ( String[] args ) {
		launch( args ); //This calls start()
	}
} public class HypnosURLS {
	//TODO: Move the other urls to here. Settings window has a lot of them, maybe all? 
	public static final String HELP_INOTIFY = "http://hypnosplayer.org/help/linux-inotify-count";
	
	public static final String DDG_IMAGE_SEARCH = "https://duckduckgo.com/?iax=images&ia=images&q=";

	public static final String DDG_SEARCH = "https://duckduckgo.com/?q=";
	
	public static String getDDGSearchURL(String searchTerms) {
		return DDG_SEARCH + searchTerms.replaceAll(" ", "+");
	}
} public class Persister {

	private static final Logger LOGGER = Logger.getLogger( Persister.class.getName() );
	
	private static final int SAVE_ALL_INTERVAL = 10000;

	public enum Setting {
		SHUFFLE, REPEAT, HIDE_ALBUM_TRACKS, WINDOW_MAXIMIZED, PRIMARY_SPLIT_PERCENT, 
		ART_CURRENT_SPLIT_PERCENT, ART_SPLIT_PERCENT, WINDOW_X_POSITION, WINDOW_Y_POSITION, 
		WINDOW_WIDTH, WINDOW_HEIGHT, TRACK, TRACK_POSITION, TRACK_NUMBER, VOLUME,
		PROMPT_BEFORE_OVERWRITE, SHOW_INOTIFY_ERROR_POPUP, SHOW_UPDATE_AVAILABLE_IN_MAIN_WINDOW, 
		SHOW_SYSTEM_TRAY_ICON, CLOSE_TO_SYSTEM_TRAY, MINIMIZE_TO_SYSTEM_TRAY, THEME, LOADER_SPEED,
		DEFAULT_SHUFFLE_ALBUMS, DEFAULT_SHUFFLE_TRACKS, DEFAULT_SHUFFLE_PLAYLISTS,
		DEFAULT_REPEAT_ALBUMS,  DEFAULT_REPEAT_TRACKS,  DEFAULT_REPEAT_PLAYLISTS,
		
		AR_TABLE_ARTIST_COLUMN_SHOW, AR_TABLE_ALBUMS_COLUMN_SHOW, AR_TABLE_TRACKS_COLUMN_SHOW, 
		AR_TABLE_LENGTH_COLUMN_SHOW,
		AL_TABLE_ARTIST_COLUMN_SHOW, AL_TABLE_YEAR_COLUMN_SHOW, AL_TABLE_ALBUM_COLUMN_SHOW, 
		AL_TABLE_ADDED_COLUMN_SHOW,
		TR_TABLE_ARTIST_COLUMN_SHOW, TR_TABLE_NUMBER_COLUMN_SHOW, TR_TABLE_TITLE_COLUMN_SHOW, 
		TR_TABLE_ALBUM_COLUMN_SHOW, TR_TABLE_LENGTH_COLUMN_SHOW, 
		PL_TABLE_PLAYLIST_COLUMN_SHOW, PL_TABLE_TRACKS_COLUMN_SHOW, PL_TABLE_LENGTH_COLUMN_SHOW,
		CL_TABLE_PLAYING_COLUMN_SHOW, CL_TABLE_NUMBER_COLUMN_SHOW, CL_TABLE_ARTIST_COLUMN_SHOW, 
		CL_TABLE_YEAR_COLUMN_SHOW, CL_TABLE_ALBUM_COLUMN_SHOW, CL_TABLE_TITLE_COLUMN_SHOW, 
		CL_TABLE_LENGTH_COLUMN_SHOW,
		
		AR_TABLE_ARTIST_COLUMN_WIDTH, AR_TABLE_ALBUMS_COLUMN_WIDTH, AR_TABLE_TRACKS_COLUMN_WIDTH, 
		AR_TABLE_LENGTH_COLUMN_WIDTH,
		AL_TABLE_ARTIST_COLUMN_WIDTH, AL_TABLE_YEAR_COLUMN_WIDTH, AL_TABLE_ALBUM_COLUMN_WIDTH, 
		AL_TABLE_ADDED_COLUMN_WIDTH,
		TR_TABLE_ARTIST_COLUMN_WIDTH, TR_TABLE_NUMBER_COLUMN_WIDTH, TR_TABLE_TITLE_COLUMN_WIDTH, 
		TR_TABLE_ALBUM_COLUMN_WIDTH, TR_TABLE_LENGTH_COLUMN_WIDTH, 
		PL_TABLE_PLAYLIST_COLUMN_WIDTH, PL_TABLE_TRACKS_COLUMN_WIDTH, PL_TABLE_LENGTH_COLUMN_WIDTH,
		CL_TABLE_PLAYING_COLUMN_WIDTH, CL_TABLE_NUMBER_COLUMN_WIDTH, CL_TABLE_ARTIST_COLUMN_WIDTH, 
		CL_TABLE_YEAR_COLUMN_WIDTH, CL_TABLE_ALBUM_COLUMN_WIDTH, CL_TABLE_TITLE_COLUMN_WIDTH, 
		CL_TABLE_LENGTH_COLUMN_WIDTH,
		
		LIBRARY_TAB_ARTISTS_VISIBLE, LIBRARY_TAB_ALBUMS_VISIBLE, LIBRARY_TAB_TRACKS_VISIBLE, 
		LIBRARY_TAB_PLAYLISTS_VISIBLE, LIBRARY_TAB, //Library Tab has to come after visible toggle
		
		//This is dumb, but column order has to come before sort order in this list
		//so column order is applied first, so it doesn't mess up sort order. 
		ARTIST_COLUMN_ORDER, ARTIST_SORT_ORDER,
		ALBUM_COLUMN_ORDER, ALBUM_SORT_ORDER, 
		TRACK_COLUMN_ORDER, TRACK_SORT_ORDER, 
		PLAYLIST_COLUMN_ORDER, PLAYLIST_SORT_ORDER, 
		CL_COLUMN_ORDER, CL_SORT_ORDER, 
		
		LASTFM_USERNAME, LASTFM_PASSWORD_MD5, LASTFM_SCROBBLE_ON_PLAY, LASTFM_SCROBBLE_TIME,  
		SHOW_LASTFM_IN_UI,
		;
	}

	private File sourcesFile;
	private File playlistsDirectory;
	private File currentFile;
	private File queueFile;
	private File historyFile;
	private File dataFile;
	private File settingsFile;
	private File hotkeysFile;

	private FXUI ui;
	private AudioSystem audioSystem;
	private Library library;
	private GlobalHotkeys hotkeys;
	
	public Persister ( FXUI ui, Library library, AudioSystem audioSystem, GlobalHotkeys hotkeys ) {

		this.ui = ui;
		this.audioSystem = audioSystem;
		this.library = library;
		this.hotkeys = hotkeys;

		File configDirectory = Hypnos.getConfigDirectory().toFile();

		sourcesFile = new File( configDirectory + File.separator + "sources" );
		playlistsDirectory = new File( configDirectory + File.separator + "playlists" );
		currentFile = new File( configDirectory + File.separator + "current" );
		queueFile = new File( configDirectory + File.separator + "queue" );
		historyFile = new File( configDirectory + File.separator + "history" );
		dataFile = new File( configDirectory + File.separator + "data" );
		settingsFile = new File( configDirectory + File.separator + "settings" );
		hotkeysFile = new File( configDirectory + File.separator + "hotkeys" );
		
		createNecessaryFolders();
	}

	private void createNecessaryFolders () {
		if ( playlistsDirectory.exists() && !playlistsDirectory.isDirectory() ) {
			try {
				Files.delete( playlistsDirectory.toPath() );
				LOGGER.info( "Playlists directory location existed but was not a directory. Removed: " + playlistsDirectory.toString() ); 
			} catch ( IOException e ) {
				ui.notifyUserError( "Playlist directory (" + playlistsDirectory + ") exists but is not a directory.\n\nPlaylist data will not be saved." );
				LOGGER.warning( "Playlists directory exists but is a normal file, and I can't remove it."
					+ " Playlist data may be lost after program is terminated."
					+ playlistsDirectory.toString()
				);
			}
		}
		
		if ( !playlistsDirectory.exists() ) {
			boolean playlistDirCreated = playlistsDirectory.mkdirs(); 
			if ( playlistDirCreated ) {
				LOGGER.info( "Playlist directory did not exist. Created: " + playlistsDirectory.toString() ); 
			} else {
				ui.notifyUserError( "Cannot create playlist directory (" + playlistsDirectory + ").\n\nPlaylist data will not be saved." );
				LOGGER.warning( "Cannot create playlists directory. Playlist data may be lost after program is terminated." + playlistsDirectory.toString() );
			}
		}
	}

	public void saveAllData( EnumMap <Setting, ? extends Object> fromAudioSystem, EnumMap <Setting, ? extends Object> fromUI ) {
		createNecessaryFolders();
		saveAlbumsAndTracks();
		saveRoots();
		saveCurrentList();
		saveQueue();
		saveHistory();
		saveLibraryPlaylists();
		saveSettings( fromAudioSystem, fromUI );
		saveHotkeys();	
	}

	public void saveSettings () {
		EnumMap <Setting, ? extends Object> fromAudioSystem = audioSystem.getSettings();
		EnumMap <Setting, ? extends Object> fromUI = ui.getSettings();
		saveSettings ( fromAudioSystem, fromUI );
	}

	public boolean loadRoots () {
		try ( ObjectInputStream rootsIn = new ObjectInputStream( new FileInputStream( sourcesFile ) ); ) {
			ArrayList <MusicRoot> musicRoots = (ArrayList <MusicRoot>) rootsIn.readObject();
			library.setMusicRootsOnInitialLoad( musicRoots );
			return true;
			
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Unable to read library directory list from disk, continuing.", e);
		}
		
		return false;
	}

	public void loadCurrentList() {
		try ( ObjectInputStream dataIn = new ObjectInputStream( new FileInputStream( currentFile ) ) ) {
			audioSystem.getCurrentList().setState ( (CurrentListState)dataIn.readObject() );
			audioSystem.getCurrentList().setHasUnsavedData( false );
			
		} catch ( Exception e ) {
			try ( ObjectInputStream dataIn = new ObjectInputStream( new GZIPInputStream( new FileInputStream( currentFile ) ) ) ) {
				audioSystem.getCurrentList().setState ( (CurrentListState)dataIn.readObject() );
				audioSystem.getCurrentList().setHasUnsavedData( false );
				
			} catch ( Exception e2 ) {
				LOGGER.log( Level.INFO, "Unable to read library data from disk, continuing.", e2 );
			}
		}
	}

	public void loadQueue () {
		try ( ObjectInputStream queueIn = new ObjectInputStream( new FileInputStream( queueFile ) ); ) {
			audioSystem.getQueue().queueAllTracks( (ArrayList <Track>) queueIn.readObject() );
			audioSystem.getQueue().setHasUnsavedData( false );
			
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Unable to read queue data from disk, continuing.", e );
		}
	}

	public void loadHistory () {
		try ( ObjectInputStream historyIn = new ObjectInputStream( new FileInputStream( historyFile ) ); ) {
			audioSystem.getHistory().setData( (ArrayList <Track>) historyIn.readObject() );
			audioSystem.getHistory().setHasUnsavedData( false );
		
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Unable to read history from disk, continuing.", e );
		}
	}
	
	public void loadHotkeys () {
		try ( ObjectInputStream hotkeysIn = new ObjectInputStream( new FileInputStream( hotkeysFile ) ); ) {
			hotkeys.setMap( (EnumMap <Hotkey, HotkeyState>) hotkeysIn.readObject() );
			hotkeys.setHasUnsavedData( false );
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Unable to read hotkeys from disk, continuing.", e );
		}
	}

	public void loadAlbumsAndTracks () {
		try ( ObjectInputStream dataIn = new ObjectInputStream( new GZIPInputStream( new FileInputStream( dataFile ) ) ) ) {
		    ArrayList <Album> albums = (ArrayList <Album>) dataIn.readObject();
		    ArrayList <Track> tracks = (ArrayList <Track>) dataIn.readObject();
		    library.setDataOnInitialLoad ( tracks, albums );
		    if ( audioSystem.getCurrentTrack() != null ) {
			    for ( Track track : library.getTrackData() ) {
			    	if ( track.equals(audioSystem.getCurrentTrack()) ) {
			    		audioSystem.getCurrentTrack().setAlbum(track.getAlbum());
			    	}
			    }
		    }
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Unable to read library data from disk, continuing.", e );
		}
	}

	public void loadPlaylists () {
		try ( DirectoryStream <Path> stream = Files.newDirectoryStream( playlistsDirectory.toPath() ); ) {
			List<Playlist> playlists = new ArrayList<>();
			for ( Path child : stream ) {
				Playlist playlist = Playlist.loadPlaylist( child );
				if ( playlist != null ) {
					playlist.setHasUnsavedData( false );
					playlists.add( playlist );
					library.linkPlaylistToLibrary(playlist);
				}
			}
			library.setDataOnInitialLoad( playlists );

		} catch ( IOException e ) {
			LOGGER.log( Level.WARNING, "Unable to load playlists from disk.", e );
		}
	}

	public void saveRoots () {
		File tempSourcesFile = new File ( sourcesFile.toString() + ".temp" );
		try ( ObjectOutputStream sourcesOut = new ObjectOutputStream( new FileOutputStream( tempSourcesFile ) ); ) {
			sourcesOut.writeObject( new ArrayList <MusicRoot> ( library.getMusicRootData() ) );
			sourcesOut.flush();
			sourcesOut.close();

			Files.move( tempSourcesFile.toPath(), sourcesFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE );
			
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to save library source directory list to disk, continuing.", e );
		}
	}
	
	public void saveCurrentList () {
		if ( !audioSystem.getCurrentList().hasUnsavedData() ) return;
		File tempCurrentFile = new File ( currentFile.toString() + ".temp" );
		
		if ( audioSystem.getCurrentList().getState().getItems().size() < 500 ) {
			try ( ObjectOutputStream currentListOut = new ObjectOutputStream( new FileOutputStream( tempCurrentFile ) ) ) {
				currentListOut.writeObject( audioSystem.getCurrentList().getState() );
				currentListOut.flush();
				currentListOut.close();
				
				Files.move( tempCurrentFile.toPath(), currentFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );

				audioSystem.getCurrentList().setHasUnsavedData( false );
				
			} catch ( Exception e ) {
				LOGGER.log( Level.WARNING, "Unable to save current list data to disk, continuing.", e );
			}
		} else {
			try ( GZIPOutputStream currentListOut = new GZIPOutputStream( new BufferedOutputStream( new FileOutputStream( tempCurrentFile ) ) ); ) {
	
				ByteArrayOutputStream byteWriter = new ByteArrayOutputStream();
				ObjectOutputStream bytesOut = new ObjectOutputStream( byteWriter );
	
				bytesOut.writeObject( audioSystem.getCurrentList().getState() );
	
				currentListOut.write( byteWriter.toByteArray() );
				currentListOut.flush();
				currentListOut.close();
				
				Files.move( tempCurrentFile.toPath(), currentFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );

				audioSystem.getCurrentList().setHasUnsavedData( false );
				
			} catch ( Exception e ) {
				LOGGER.log( Level.WARNING, "Unable to save current list data to disk, continuing.", e );
			}
		}
	}

	public void saveQueue () {
		if ( !audioSystem.getQueue().hasUnsavedData() ) {
			return;
		}
		
		File tempQueueFile = new File ( queueFile.toString() + ".temp" );
		try ( ObjectOutputStream queueListOut = new ObjectOutputStream( new FileOutputStream( tempQueueFile ) ) ) {
			queueListOut.writeObject( new ArrayList <Track>( audioSystem.getQueue().getData() ) );
			queueListOut.flush();
			queueListOut.close();
			
			Files.move( tempQueueFile.toPath(), queueFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );
			
			audioSystem.getQueue().setHasUnsavedData( false );
			
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to save queue to disk, continuing.", e );
		}
	}

	public void saveHistory () {
		if ( !audioSystem.getHistory().hasUnsavedData() ) return;

		File tempHistoryFile = new File ( historyFile.toString() + ".temp" );
		
		try ( ObjectOutputStream historyListOut = new ObjectOutputStream( new FileOutputStream( tempHistoryFile ) ) ) {
			
			historyListOut.writeObject( new ArrayList <Track>( audioSystem.getHistory().getItems() ) );
			historyListOut.flush();
			historyListOut.close();
			
			Files.move( tempHistoryFile.toPath(), historyFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );
			
			audioSystem.getHistory().setHasUnsavedData( false );
			
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to save history to disk, continuing.", e );
		}
	}
	
	public void saveHotkeys () {
		if ( !hotkeys.hasUnsavedData() ) return;
		
		File tempHotkeysFile = new File ( hotkeysFile.toString() + ".temp" );

		try ( ObjectOutputStream hotkeysOut = new ObjectOutputStream( new FileOutputStream( tempHotkeysFile ) ) ) {
			hotkeysOut.writeObject( hotkeys.getMap() );
			hotkeysOut.flush();
			hotkeysOut.close();
			
			Files.move( tempHotkeysFile.toPath(), hotkeysFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE );

			hotkeys.setHasUnsavedData( false );
			
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to save hotkeys to disk, continuing.", e );
		}
	}

	public void saveAlbumsAndTracks () {
		/*
		 * Some notes for future Josh (2017/05/14): 
		 * 1. For some reason, keepingthe ByteArrayOutputStream in the middle 
		 * makes things take ~2/3 the amount of time. 
		 * 2. I tried removing tracks that have albums (since they're being 
		 * written twice) but it didn't create any savings. I guess compression
		 * is handling that.
		 * 3. I didn't try regular zip. GZIP was easier.
		 */

		File tempDataFile = new File ( dataFile.toString() + ".temp" );
		
		try ( GZIPOutputStream compressedOut = new GZIPOutputStream( new BufferedOutputStream( new FileOutputStream( tempDataFile ) ) ); ) {

			ByteArrayOutputStream byteWriter = new ByteArrayOutputStream();
			ObjectOutputStream bytesOut = new ObjectOutputStream( byteWriter );

			bytesOut.writeObject( new ArrayList <Album>( Arrays.asList( library.getAlbumData().toArray( new Album [ library.getAlbumData().size() ] ) ) ) );
			bytesOut.writeObject( new ArrayList <Track>( Arrays.asList( library.getTrackData().toArray( new Track [ library.getTrackData().size() ] ) ) ) );

			compressedOut.write( byteWriter.toByteArray() );
			compressedOut.flush();
			compressedOut.close();
			
			Files.move( tempDataFile.toPath(), dataFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );

		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to save library data to disk, continuing.", e );
		}
	}

	public void saveLibraryPlaylists () {
		
		ArrayList <Playlist> playlists = new ArrayList <> ( library.getPlaylistData() );
		
		ArrayList <Playlist> errors = new ArrayList <> ();

		for ( Playlist playlist : playlists ) {
			if ( playlist == null ) {
				LOGGER.log( Level.INFO, "Found a null playlist in library.playlists, ignoring.", new NullPointerException() );
				continue;
			}
			
			if ( !playlist.hasUnsavedData() ) continue;
			
			try {
				saveLibaryPlaylist ( playlist );
			} catch ( IOException e ) {
				LOGGER.log ( Level.WARNING, "Unable to save library playlist " + playlist.getName(), e );
				errors.add( playlist );
			}
			
		}
		
		if ( errors.size() > 0 ) {
			Hypnos.warnUserPlaylistsNotSaved ( errors ); 
		}
	}
	
	public void deletePlaylistFile ( Playlist playlist ) {
		deletePlaylistFile ( playlist.getBaseFilename() );
	}
	
	public void deletePlaylistFile ( String basename ) {
		Path targetFile = playlistsDirectory.toPath().resolve (  basename + ".m3u" );
		
		try {
			Files.deleteIfExists( targetFile );
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to delete playlist file: " + targetFile, e );
		}
	}
	
	//Assumptions: playlist != null, playlist.name is not null or empty, and no playlists in library have the same name. 
	private void saveLibaryPlaylist ( Playlist playlist ) throws IOException {
		
		String baseFileName = playlist.getBaseFilename();
		Path targetFile = playlistsDirectory.toPath().resolve (  baseFileName + ".m3u" );
		Path backupFile = playlistsDirectory.toPath().resolve ( baseFileName + ".m3u.backup" );
		Path tempFile = playlistsDirectory.toPath().resolve ( baseFileName + ".m3u.temp" );
		
		boolean savedToTemp = false;
		
		try {
			playlist.saveAs( tempFile.toFile() );
			savedToTemp = true;
		} catch ( IOException e ) {
			savedToTemp = false;
			LOGGER.log( Level.INFO, "Unable to write to a temp file, so I will try writing directly to the playlist file." +
				"Your data will be saved in a backup file first. If this process is interrupted, you may need to manually " +
				"recover the data from the backup file.", e );
			
			if ( Files.exists( targetFile ) ) {
				try {
					Files.move( targetFile, backupFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );
				} catch ( IOException e2 ) {
					LOGGER.log( Level.INFO, "Unable to move existing playlist file to backup location (" + backupFile.toString() +
						") will continue trying to save current playlist, overwriting the existing file.", e2 );
				}
			}
		}
		
		try {
			
			boolean movedFromTemp = false;
			
			if ( savedToTemp ) {
				try {
					Files.move( tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );
					movedFromTemp = true;
				} catch ( IOException e ) {
					movedFromTemp = false;
				}
			}
			
			if ( !movedFromTemp ) {
				playlist.saveAs( targetFile.toFile() );
			}
			
		} catch ( IOException e ) {
			LOGGER.log( Level.WARNING, "Unable to save playlist to file: " + targetFile.toString() + ".", e );
			throw e;
			
		} finally {
			Files.deleteIfExists( tempFile );
			
		}

		playlist.setHasUnsavedData( false );
	}
	
	public void saveSettings ( EnumMap <Setting, ? extends Object> fromAudioSystem, EnumMap <Setting, ? extends Object> fromUI ) {
		
		File tempSettingsFile = new File ( settingsFile.toString() + ".temp" );

		try ( FileWriter fileWriter = new FileWriter( tempSettingsFile ); ) {
			PrintWriter settingsOut = new PrintWriter( new BufferedWriter( fileWriter ) );

			fromAudioSystem.forEach( ( key, value ) -> {
				String valueOut = value == null ? "null" : value.toString();
				settingsOut.printf( "%s: %s%s", key, valueOut, System.lineSeparator() );
			} );

			fromUI.forEach( ( key, value ) -> {
				String valueOut = value == null ? "null" : value.toString();
				settingsOut.printf( "%s: %s%s", key, valueOut, System.lineSeparator() );
			} );
			
			settingsOut.printf( "%s: %s%s", Setting.LOADER_SPEED, Hypnos.getLoaderSpeed(), System.lineSeparator() );

			settingsOut.flush();
			settingsOut.close();
			
			Files.move( tempSettingsFile.toPath(), settingsFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE  );

		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to save settings to disk, continuing.", e );
		}
	}
	
	public EnumMap <Setting, String> loadSettingsFromDisk () {
		EnumMap <Setting, String> settings = new EnumMap <Setting, String>( Setting.class );

		try ( FileReader fileReader = new FileReader( settingsFile ) ) {
			BufferedReader settingsIn = new BufferedReader( fileReader );
			for ( String line; (line = settingsIn.readLine()) != null; ) {
				Setting setting;
				try {
					setting = Setting.valueOf( line.split( ":\\s+" )[0] );
				} catch ( IllegalArgumentException e ) {
					LOGGER.log( Level.INFO, "Found invalid setting: " + line.split( ":\\s+" )[0] + ", ignoring.", e );
					continue;
				}
				
				String value = "";
				try {
					value = line.split( ":\\s+" )[1];
				} catch ( ArrayIndexOutOfBoundsException e ) {
					//Do nothing, some settings can be empty
				}
					
				settings.put ( setting, value );
			}

		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to read settings from disk, continuing.", e );
		}
		
		return settings;
	}

	public void logUnusedSettings ( EnumMap <Setting, String> pendingSettings ) {
		if ( pendingSettings.size() == 0 ) return;
		
		String message = "";
		for ( Setting setting : pendingSettings.keySet() ) {
			if ( message.length() > 0 ) message += "\n";
			message += setting.toString() + ": " + pendingSettings.get( setting );
		}
		
		LOGGER.log ( Level.INFO, "Some settings were read from disk but not applied:\n" + message );
	}

	public void startThread() {
		Thread persisterThread = new Thread() {
			public void run() {
				long lastSaveTime = System.currentTimeMillis();
				while ( true ) {
					 if ( System.currentTimeMillis() - lastSaveTime > SAVE_ALL_INTERVAL ) {

							createNecessaryFolders();
							
					    if ( library.dataNeedsToBeSavedToDisk() ) {
					       saveAlbumsAndTracks();
					       library.setDataNeedsToBeSavedToDisk( false );
					    }
					    
							saveRoots();
							saveCurrentList();
							saveQueue();
							saveHistory();
							saveLibraryPlaylists();
							saveHotkeys();	

					    //there's no easy way to check if settings changed right now, so we just don't bother
					    //it's not a big deal if they are lost due to a crash. They are saved on close
					    //persister.saveSettings();

					    lastSaveTime = System.currentTimeMillis();
					 }
					
					
					try {
						Thread.sleep( 100 );
					} catch (InterruptedException e) {
						LOGGER.log( Level.INFO, "Persister thread interrupted, ignoring.", e );
					}
				}
			}
		};
		
		persisterThread.setName( "Persister Thread" );
		persisterThread.start();
	}

	public Path getPlaylistDirectory() {
		return this.playlistsDirectory.toPath();
	}
} public class Utils {

	private static transient final Logger LOGGER = Logger.getLogger(Utils.class.getName());

	private static String[] imageExtStrings = new String[] { "jpg", "jpeg", "png", "gif" };
	private static String[] playlistExtStrings = new String[] { "m3u" };

	public static final DirectoryStream.Filter<Path> musicFileFilter = new DirectoryStream.Filter<Path>() {
		@Override
		public boolean accept(Path entry) throws IOException {
			return isMusicFile(entry);
		}
	};

	public static boolean hasImageExtension(String url) {
		String testExtension = url.substring(url.lastIndexOf(".") + 1).toLowerCase();
		for (String imageExtension : imageExtStrings) {
			if (imageExtension.toLowerCase().equals(testExtension)) {
				return true;
			}
		}
		return false;
	}

	public static boolean isChildOf(Path potentialChild, Path parent) {
		parent = parent.normalize().toAbsolutePath();
		Path test = potentialChild.getParent();
		while (test != null) {
			if (test.equals(parent)) {
				return true;
			}
			test = test.getParent();
		}
		return false;
	}

	public static boolean isImageFile(File testFile) {
		return isImageFile(testFile.toPath());
	}

	public static boolean isImageFile(Path testFile) {
		String fileName = testFile.getFileName().toString();
		if (!Files.exists(testFile)) {
			return false;
		} else if (!Files.isRegularFile(testFile)) {
			return false;
		} else if (fileName.lastIndexOf(".") == -1 || fileName.lastIndexOf(".") == 0) {
			return false;
		}
		return hasImageExtension(fileName);
	}

	public static String toReleaseTitleCase(String input) {
		if (input.equalsIgnoreCase("EP")) {
			return "EP";
		} else {
			StringBuilder titleCase = new StringBuilder();
			boolean nextTitleCase = true;
			for (char c : input.toCharArray()) {
				if (Character.isSpaceChar(c)) {
					nextTitleCase = true;
				} else if (nextTitleCase) {
					c = Character.toTitleCase(c);
					nextTitleCase = false;
				}
				titleCase.append(c);
			}
			return titleCase.toString();
		}
	}

	public static boolean isMusicFile(String testFile) {
		return isMusicFile(Paths.get(testFile));
	}

	public static boolean isMusicFile(Path testFile) {
		if (testFile == null) {
			LOGGER.log(Level.INFO, "Asked if a null path was a music file, returning false.", new NullPointerException());
			return false;
		}
		String fileName = testFile.getFileName().toString();
		if (!Files.exists(testFile)) {
			return false;
		} else if (!Files.isRegularFile(testFile)) {
			return false;
		} else if (fileName.lastIndexOf(".") == -1 || fileName.lastIndexOf(".") == 0) {
			return false;
		} else if (fileName.startsWith("._")) {
			return false;
		}
		String testExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
		Format format = Format.getFormat(testExtension);
		return format != null;
	}

	public static boolean isPlaylistFile(Path testFile) {
		String fileName = testFile.getFileName().toString();
		if (!Files.exists(testFile)) {
			return false;
		} else if (!Files.isRegularFile(testFile)) {
			return false;
		} else if (fileName.lastIndexOf(".") == -1 || fileName.lastIndexOf(".") == 0) {
			return false;
		}
		String testExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
		for (String playlistExtension : playlistExtStrings) {
			if (playlistExtension.toLowerCase().equals(testExtension)) {
				return true;
			}
		}
		return false;
	}

	public static String getFileExtension(Path path) {
		return getFileExtension(path.toFile());
	}

	public static String getFileExtension(File file) {
		String name = file.getName();
		try {
			return name.substring(name.lastIndexOf(".") + 1);
		} catch (Exception e) {
			return "";
		}
	}

	public static String getLengthDisplay(int lengthSeconds) {
		boolean negative = lengthSeconds < 0;
		lengthSeconds = Math.abs(lengthSeconds);
		int hours = lengthSeconds / 3600;
		int minutes = (lengthSeconds % 3600) / 60;
		int seconds = lengthSeconds % 60;
		if (hours > 0) {
			return String.format((negative ? "-" : "") + "%d:%02d:%02d", hours, minutes, seconds);
		} else {
			return String.format((negative ? "-" : "") + "%d:%02d", minutes, seconds);
		}
	}

	public static ArrayList<CurrentListTrack> convertTrackList(List<Track> tracks) {
		ArrayList<CurrentListTrack> retMe = new ArrayList<CurrentListTrack>(tracks.size());
		for (Track track : tracks) {
			if (track instanceof CurrentListTrack) {
				retMe.add((CurrentListTrack) track);
			} else {
				retMe.add(new CurrentListTrack(track));
			}
		}
		return retMe;
	}

	public static ArrayList<Track> convertCurrentTrackList(List<CurrentListTrack> tracks) {
		ArrayList<Track> retMe = new ArrayList<Track>(tracks.size());
		for (CurrentListTrack track : tracks) {
			retMe.add((Track) track);
		}
		return retMe;
	}

	public static String prepareArtistForCompare(String string) {
		return string.toLowerCase().replaceAll(" & ", " and ").replaceAll("&", " and ").replaceAll(" \\+ ", " and ")
				.replaceAll("\\+", " and ").replaceAll("  ", " ").replaceAll(" the ", "").replaceAll("^the ", "")
				.replaceAll(", the$", "").replaceAll("-", " ").replaceAll("\\.", "").replaceAll("_", " ");
	}

	public static String prepareAlbumForCompare(String string) {
		return string.toLowerCase();
	}

	public static ArrayList<Path> getAllTracksInDirectory(Path startingDirectory) {
		TrackFinder finder = new TrackFinder();
		try {
			Files.walkFileTree(startingDirectory, finder);
			return finder.trackPaths;

		} catch (Exception e) {
			LOGGER.log(Level.WARNING,
					"Read error while traversing directory, some files may not have been loaded: " + startingDirectory.toString(),
					e);
		}

		return new ArrayList<Path>();
	}

	public static boolean saveImageToDisk(Path location, byte[] buffer) {
		if (location == null) {
			return false;
		}
		InputStream in = new ByteArrayInputStream(buffer);
		try {
			BufferedImage bImage = ImageIO.read(in);
			ImageIO.write(bImage, "png", location.toFile());
			return true;
		} catch (Exception e) {
			LOGGER.log(Level.WARNING, "Unable to save image to location: " + location, e);
		}
		return false;
	}
} public enum Setting {
	SHUFFLE, REPEAT, HIDE_ALBUM_TRACKS, WINDOW_MAXIMIZED, PRIMARY_SPLIT_PERCENT, 
	ART_CURRENT_SPLIT_PERCENT, ART_SPLIT_PERCENT, WINDOW_X_POSITION, WINDOW_Y_POSITION, 
	WINDOW_WIDTH, WINDOW_HEIGHT, TRACK, TRACK_POSITION, TRACK_NUMBER, VOLUME,
	PROMPT_BEFORE_OVERWRITE, SHOW_INOTIFY_ERROR_POPUP, SHOW_UPDATE_AVAILABLE_IN_MAIN_WINDOW, 
	SHOW_SYSTEM_TRAY_ICON, CLOSE_TO_SYSTEM_TRAY, MINIMIZE_TO_SYSTEM_TRAY, THEME, LOADER_SPEED,
	DEFAULT_SHUFFLE_ALBUMS, DEFAULT_SHUFFLE_TRACKS, DEFAULT_SHUFFLE_PLAYLISTS,
	DEFAULT_REPEAT_ALBUMS,  DEFAULT_REPEAT_TRACKS,  DEFAULT_REPEAT_PLAYLISTS,
		
	AR_TABLE_ARTIST_COLUMN_SHOW, AR_TABLE_ALBUMS_COLUMN_SHOW, AR_TABLE_TRACKS_COLUMN_SHOW, 
	AR_TABLE_LENGTH_COLUMN_SHOW,
	AL_TABLE_ARTIST_COLUMN_SHOW, AL_TABLE_YEAR_COLUMN_SHOW, AL_TABLE_ALBUM_COLUMN_SHOW, 
	AL_TABLE_ADDED_COLUMN_SHOW,
	TR_TABLE_ARTIST_COLUMN_SHOW, TR_TABLE_NUMBER_COLUMN_SHOW, TR_TABLE_TITLE_COLUMN_SHOW, 
	TR_TABLE_ALBUM_COLUMN_SHOW, TR_TABLE_LENGTH_COLUMN_SHOW, 
	PL_TABLE_PLAYLIST_COLUMN_SHOW, PL_TABLE_TRACKS_COLUMN_SHOW, PL_TABLE_LENGTH_COLUMN_SHOW,
	CL_TABLE_PLAYING_COLUMN_SHOW, CL_TABLE_NUMBER_COLUMN_SHOW, CL_TABLE_ARTIST_COLUMN_SHOW, 
	CL_TABLE_YEAR_COLUMN_SHOW, CL_TABLE_ALBUM_COLUMN_SHOW, CL_TABLE_TITLE_COLUMN_SHOW, 
	CL_TABLE_LENGTH_COLUMN_SHOW,
		
	AR_TABLE_ARTIST_COLUMN_WIDTH, AR_TABLE_ALBUMS_COLUMN_WIDTH, AR_TABLE_TRACKS_COLUMN_WIDTH, 
	AR_TABLE_LENGTH_COLUMN_WIDTH,
	AL_TABLE_ARTIST_COLUMN_WIDTH, AL_TABLE_YEAR_COLUMN_WIDTH, AL_TABLE_ALBUM_COLUMN_WIDTH, 
	AL_TABLE_ADDED_COLUMN_WIDTH,
	TR_TABLE_ARTIST_COLUMN_WIDTH, TR_TABLE_NUMBER_COLUMN_WIDTH, TR_TABLE_TITLE_COLUMN_WIDTH, 
	TR_TABLE_ALBUM_COLUMN_WIDTH, TR_TABLE_LENGTH_COLUMN_WIDTH, 
	PL_TABLE_PLAYLIST_COLUMN_WIDTH, PL_TABLE_TRACKS_COLUMN_WIDTH, PL_TABLE_LENGTH_COLUMN_WIDTH,
	CL_TABLE_PLAYING_COLUMN_WIDTH, CL_TABLE_NUMBER_COLUMN_WIDTH, CL_TABLE_ARTIST_COLUMN_WIDTH, 
	CL_TABLE_YEAR_COLUMN_WIDTH, CL_TABLE_ALBUM_COLUMN_WIDTH, CL_TABLE_TITLE_COLUMN_WIDTH, 
	CL_TABLE_LENGTH_COLUMN_WIDTH,
		
	LIBRARY_TAB_ARTISTS_VISIBLE, LIBRARY_TAB_ALBUMS_VISIBLE, LIBRARY_TAB_TRACKS_VISIBLE, 
	LIBRARY_TAB_PLAYLISTS_VISIBLE, LIBRARY_TAB, //Library Tab has to come after visible toggle
		
	//This is dumb, but column order has to come before sort order in this list
	//so column order is applied first, so it doesn't mess up sort order. 
	ARTIST_COLUMN_ORDER, ARTIST_SORT_ORDER,
	ALBUM_COLUMN_ORDER, ALBUM_SORT_ORDER, 
	TRACK_COLUMN_ORDER, TRACK_SORT_ORDER, 
	PLAYLIST_COLUMN_ORDER, PLAYLIST_SORT_ORDER, 
	CL_COLUMN_ORDER, CL_SORT_ORDER, 
		
	LASTFM_USERNAME, LASTFM_PASSWORD_MD5, LASTFM_SCROBBLE_ON_PLAY, LASTFM_SCROBBLE_TIME,  
	SHOW_LASTFM_IN_UI,
	;
} public class AudioSystem {
	
	private static final Logger LOGGER = Logger.getLogger( AudioSystem.class.getName() );
	
	public enum StopReason {
		TRACK_FINISHED,
		USER_REQUESTED,
		END_OF_CURRENT_LIST,
		EMPTY_LIST,
		WRITING_TO_TAG,
		UNABLE_TO_START_TRACK,
		ERROR
	}

	public enum ShuffleMode {
		SEQUENTIAL ( "⇉" ), SHUFFLE ( "🔀" );
		String symbol;
		ShuffleMode ( String symbol ) { this.symbol = symbol; }
		public String getSymbol () { return symbol; }
	}

	public enum RepeatMode {
		PLAY_ONCE ( "⇥" ), REPEAT ( "🔁" ), REPEAT_ONE_TRACK ( "🔂" );
		String symbol;
		RepeatMode ( String symbol ) { this.symbol = symbol; }
		public String getSymbol () { return symbol; }
	}
	
	private ShuffleMode shuffleMode = ShuffleMode.SEQUENTIAL;
	private RepeatMode repeatMode = RepeatMode.PLAY_ONCE;
	
	private Vector<PlayerListener> playerListeners = new Vector<PlayerListener> ();
	
	private Random randomGenerator = new Random();
	
	private int shuffleTracksPlayedCounter = 0;
	
	private final VLCAudioPlayer player;
	private final Queue queue;
	private final History history; 
	private final PreviousStack previousStack;
	private final CurrentList currentList;
	private final LastFM lastFM;
	
	private Double unmutedVolume = null;
	
	private final BooleanProperty lastFMDoScrobble = new SimpleBooleanProperty ( false );
	private boolean scrobbledThisTrack = false;
	private final DoubleProperty lastFMScrobbleTime = new SimpleDoubleProperty ( 0 );
	
	private FXUI ui;
	
	public AudioSystem () {
		player = new VLCAudioPlayer ( this );
		queue = new Queue();
		history = new History();
		previousStack = new PreviousStack();
		currentList = new CurrentList( this, queue );
		lastFM = new LastFM();
	}
	
	public void setUI ( FXUI ui ) {
		this.ui = ui;
	}
	
	public void unpause () {
		player.requestUnpause();
	}
	
	public void pause () {
		player.requestPause();
	}
	
	public void togglePlayPause () {
		if ( player.isStopped() ) {
			play();
		} else {
			togglePause();
		}
	}
	
	public void togglePause () {
		player.requestTogglePause();
	}
	
	public void play () {
		switch ( player.getState() ) {
			case PAUSED:
				player.requestUnpause();
				break;
			case PLAYING:
				this.playTrack( player.getTrack(), false );
				break;
			case STOPPED:
				next( false );
				break;
		}
	}
	
	public void stop ( StopReason reason ) {
		Track stoppedTrack = player.getTrack();

		player.requestStop();
		
		for ( CurrentListTrack track : currentList.getItems() ) {
			track.setIsCurrentTrack( false );
			track.setIsLastCurrentListTrack( false );
		}

		notifyListenersStopped( stoppedTrack, reason ); 
		
		shuffleTracksPlayedCounter = 0;
	}
	
	public void releaseResources() {
		player.releaseResources();
	}
	
	public void previous ( ) {
		boolean startPaused = player.isPaused() || player.isStopped();
		previous ( startPaused );
	}
	
	public void previous ( boolean startPaused ) {
		
		if ( player.isPlaying() || player.isPaused() ) {
			
			if ( player.getPositionMS() >= 5000 ) {
				playTrack ( player.getTrack(), player.isPaused(), false );
				return;
			}
		}		
		
		Track previousTrack = null;

		int previousStackSize = previousStack.size();
		while ( !previousStack.isEmpty() && previousTrack == null ) {
			Track candidate = previousStack.removePreviousTrack( player.getTrack() );
								
			if ( currentList.getItems().contains( candidate ) ) {
				previousTrack = candidate;
			}
		}
		
		int previousStackSizeDifference = previousStackSize - previousStack.size();
		shuffleTracksPlayedCounter -= previousStackSizeDifference;
		
		if ( previousTrack != null ) {
			playTrack ( previousTrack, startPaused, true );
			
		} else if ( repeatMode == RepeatMode.PLAY_ONCE || repeatMode == RepeatMode.REPEAT_ONE_TRACK ) {
			shuffleTracksPlayedCounter = 1;
			Track previousTrackInList = null;
			for ( CurrentListTrack track : currentList.getItems() ) {
				if ( track.getIsCurrentTrack() ) {
					if ( previousTrackInList != null ) {
						playTrack( previousTrackInList, startPaused, false );
					} else {
						playTrack( track, startPaused, false );
					}
					break;
				} else {
					previousTrackInList = track;
				}
			}
		} else if ( repeatMode == RepeatMode.REPEAT ) {
			Track previousTrackInList = null;
			for ( CurrentListTrack track : currentList.getItems() ) {
				if ( track.getIsCurrentTrack() ) {
					if ( previousTrackInList != null ) {
						playTrack( previousTrackInList, startPaused, false );
					} else {
						playTrack( currentList.getItems().get( currentList.getItems().size() - 1 ), startPaused, false );
					}
					break;
				} else {
					previousTrackInList = track;
				}
			}
		}
	}
	
	public void next() {
		boolean startPaused = player.isPaused() || player.isStopped();
		next ( startPaused );
	}
	
	public void next ( boolean startPaused ) {
		
		List<CurrentListTrack> items = currentList.getSortedItemsNoFilter();

		if ( queue.hasNext() ) {
			playTrack( queue.getNextTrack(), startPaused );
			
		} else if ( items.size() == 0 ) {
			stop ( StopReason.EMPTY_LIST );
			return;
			
		} else if ( repeatMode == RepeatMode.REPEAT_ONE_TRACK ) {
			playTrack ( history.getLastTrack() );

		} else if ( shuffleMode == ShuffleMode.SEQUENTIAL ) {
			ListIterator <CurrentListTrack> currentListIterator = items.listIterator();
			boolean didSomething = false;
			
			while ( currentListIterator.hasNext() ) {
				if ( currentListIterator.next().isLastCurrentListTrack() ) {
					if ( currentListIterator.hasNext() ) {
						playTrack( currentListIterator.next(), startPaused );
						didSomething = true;
						
					} else if ( repeatMode == RepeatMode.PLAY_ONCE ) {
						shuffleTracksPlayedCounter = 1;
						stop( StopReason.END_OF_CURRENT_LIST );
						didSomething = true;
					
					} else if ( items.size() <= 0 ) {
						stop( StopReason.EMPTY_LIST );
						didSomething = true;

					} else if ( repeatMode == RepeatMode.REPEAT && items.size() > 0 ) {
						playTrack( items.get( 0 ), startPaused );
						didSomething = true;
					}
					
					break;
				}
			}
			if ( !didSomething ) {
				if ( items.size() > 0 ) {
					playTrack ( items.get( 0 ), startPaused );
				}
			}
			
		} else if ( shuffleMode == ShuffleMode.SHUFFLE ) {
			if ( repeatMode == RepeatMode.REPEAT ) {
				
				shuffleTracksPlayedCounter = 1;
				int currentListSize = items.size();
				int collisionWindowSize = currentListSize / 3; 
				int permittedRetries = 3; 
	
				boolean foundMatch = false;
				int retryCount = 0;
				Track playMe;
	
				List <Track> collisionWindow;
	
				if ( previousStack.size() >= collisionWindowSize ) {
					collisionWindow = previousStack.subList( 0, collisionWindowSize );
				} else {
					collisionWindow = previousStack.getData();
				}
	
				do {
					playMe = items.get( randomGenerator.nextInt( items.size() ) );
					if ( !collisionWindow.contains( playMe ) ) {
						foundMatch = true;
					} else {
						++retryCount;
					}
				} while ( !foundMatch && retryCount < permittedRetries );
	
				playTrack( playMe, startPaused );
				
			} else {
				if ( shuffleTracksPlayedCounter < items.size() ) {
					List <Track> alreadyPlayed = previousStack.subList( 0, shuffleTracksPlayedCounter );
					ArrayList <Track> viableTracks = new ArrayList <Track>( items );
					viableTracks.removeAll( alreadyPlayed );
					Track playMe = viableTracks.get( randomGenerator.nextInt( viableTracks.size() ) );
					playTrack( playMe, startPaused );
					++shuffleTracksPlayedCounter;
				} else {
					stop( StopReason.END_OF_CURRENT_LIST );
				}
			} 
		}
	}
		
	public int getCurrentTrackIndex() {
		for ( int k = 0 ; k < currentList.getItems().size(); k++ ) {
			if ( currentList.getItems().get( k ).getIsCurrentTrack() ) {
				return k;
			}
		}
		
		return -1;
	}
	
	public void setShuffleMode ( ShuffleMode newMode ) {
		this.shuffleMode = newMode;
		notifyListenersShuffleModeChanged ( shuffleMode );
	}

	public ShuffleMode getShuffleMode() {
		return shuffleMode;
	}

	public void toggleShuffleMode() {
		if ( shuffleMode == ShuffleMode.SEQUENTIAL ) {
			shuffleMode = ShuffleMode.SHUFFLE;
		} else {
			shuffleMode = ShuffleMode.SEQUENTIAL;
		}
		
		notifyListenersShuffleModeChanged ( shuffleMode );
	}
	
	public void setRepeatMode ( RepeatMode newMode ) {
		this.repeatMode = newMode;
		notifyListenersRepeatModeChanged ( newMode );
	}
	
	public RepeatMode getRepeatMode() {
		return repeatMode;
	}
	
	public void toggleRepeatMode() {
		if ( repeatMode == RepeatMode.PLAY_ONCE ) {
			repeatMode = RepeatMode.REPEAT;
		} else if ( repeatMode == RepeatMode.REPEAT ) {
			repeatMode = RepeatMode.REPEAT_ONE_TRACK;
		} else {
			repeatMode = RepeatMode.PLAY_ONCE;
		}
		
		notifyListenersRepeatModeChanged ( repeatMode );
	}
	
	public History getHistory () {
		return history;
	}
	
	public Queue getQueue() {
		return queue;
	}
	
	public CurrentList getCurrentList() {
		return currentList;
	}
	
	public Track getCurrentTrack() {
		return player.getTrack();
	}

	public Playlist getCurrentPlaylist () {
		return currentList.getCurrentPlaylist();
	}
	
	public void shuffleList() {
		currentList.shuffleList( );
	}

	public void seekPercent ( double percent ) {
		player.requestSeekPercent( percent );
	}
	
	public void skipMS ( int diffMS ) {
		player.requestIncrementMS ( diffMS );
	}

	public void seekMS ( long ms ) {
		player.requestSeekMS( ms );
	}
	
	public long getPositionMS() {
		return player.getPositionMS();
	}

	public void setVolumePercent ( double percent ) {
		player.requestVolumePercent( percent );
	}
	
	public double getVolumePercent () {
		return player.getVolumePercent();
	}

	public void decrementVolume () {
		double target = player.getVolumePercent() - .05;
		if ( target < 0 ) target = 0;
		player.requestVolumePercent( target );
	}
	
	public void incrementVolume () {
		double target = player.getVolumePercent() + .05;
		if ( target > 1 ) target = 1;
		player.requestVolumePercent( target );
	}
	
	public boolean isPlaying () {
		return player.isPlaying();
	}
	
	public boolean isPaused() {
		return player.isPaused();
	}

	public boolean isStopped () {
		return player.isStopped();
	}
	
	public EnumMap <Persister.Setting, String> getSettings () {
		EnumMap <Persister.Setting, String> retMe = new EnumMap <Persister.Setting, String> ( Persister.Setting.class );
		
		if ( !player.isStopped() ) {
			if ( player.getTrack() != null ) {
				retMe.put ( Setting.TRACK, player.getTrack().getPath().toString() );
			}
			retMe.put ( Setting.TRACK_POSITION, Long.toString( player.getPositionMS() ) );
			retMe.put ( Setting.TRACK_NUMBER, Integer.toString ( getCurrentTrackIndex() ) );
		}

		retMe.put ( Setting.SHUFFLE, getShuffleMode().toString() );
		retMe.put ( Setting.REPEAT, getRepeatMode().toString() );
		retMe.put ( Setting.VOLUME, Double.toString( player.getVolumePercent() ) );
		
		retMe.put ( Setting.DEFAULT_SHUFFLE_TRACKS, currentList.getDefaultTrackShuffleMode().toString() );
		retMe.put ( Setting.DEFAULT_SHUFFLE_ALBUMS, currentList.getDefaultAlbumShuffleMode().toString() );
		retMe.put ( Setting.DEFAULT_SHUFFLE_PLAYLISTS, currentList.getDefaultPlaylistShuffleMode().toString() );

		retMe.put ( Setting.DEFAULT_REPEAT_TRACKS, currentList.getDefaultTrackRepeatMode().toString() );
		retMe.put ( Setting.DEFAULT_REPEAT_ALBUMS, currentList.getDefaultAlbumRepeatMode().toString() );
		retMe.put ( Setting.DEFAULT_REPEAT_PLAYLISTS, currentList.getDefaultPlaylistRepeatMode().toString() );
		
		retMe.put( Setting.LASTFM_USERNAME, lastFM.getUsername() );
		
		retMe.put( Setting.LASTFM_PASSWORD_MD5, lastFM.getPasswordMD5() );
		
		retMe.put( Setting.LASTFM_SCROBBLE_ON_PLAY, lastFMDoScrobble.getValue().toString() );
		
		retMe.put( Setting.LASTFM_SCROBBLE_TIME, this.lastFMScrobbleTime.getValue().toString() );
		
		
		return retMe;
	}
	
	@SuppressWarnings("incomplete-switch")
	public void applySettings ( EnumMap <Persister.Setting, String> settings ) {
		
		settings.forEach( ( setting, value )-> {
			try {
				switch ( setting ) {
				case TRACK:
					Path trackPath = Paths.get( value );
					Track track = new Track ( trackPath, false );
					ui.trackSelected( track ); 
					playTrack( track, true );
					settings.remove ( setting );
					//NOTE: track.album is set in Persister.loadAlbumsAndTracks()
					break;
					
				case TRACK_POSITION:
					seekMS( Long.parseLong( value ) );
					settings.remove ( setting );
					playerTrackPositionChanged ( player.getTrack(), (int)Long.parseLong( value ), player.getTrack().getLengthS() * 1000 );
					break;
					
				case SHUFFLE:
					setShuffleMode ( AudioSystem.ShuffleMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case REPEAT:
					setRepeatMode ( AudioSystem.RepeatMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case VOLUME:
					setVolumePercent( Double.valueOf ( value ) );
					Hypnos.getUI().playerVolumeChanged ( Double.valueOf ( value ) ); //TODO: this is kind of a hack. 
					settings.remove ( setting );
					break;
					
				case TRACK_NUMBER:
					try {
						int tracklistNumber = Integer.parseInt( value );
						if ( tracklistNumber != -1 ) {
							getCurrentList().getItems().get( tracklistNumber ).setIsCurrentTrack( true );
						}
					} catch ( Exception e ) {
						LOGGER.log( Level.INFO, "Error loading current list track number: ", e );
					}
		
					settings.remove ( setting );
					break;
					
				case DEFAULT_REPEAT_ALBUMS:
					getCurrentList().setDefaultAlbumRepeatMode( CurrentList.DefaultRepeatMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case DEFAULT_REPEAT_PLAYLISTS:
					getCurrentList().setDefaultPlaylistRepeatMode( CurrentList.DefaultRepeatMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case DEFAULT_REPEAT_TRACKS:
					getCurrentList().setDefaultTrackRepeatMode( CurrentList.DefaultRepeatMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case DEFAULT_SHUFFLE_ALBUMS:
					getCurrentList().setDefaultAlbumShuffleMode( CurrentList.DefaultShuffleMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case DEFAULT_SHUFFLE_PLAYLISTS:
					getCurrentList().setDefaultPlaylistShuffleMode( CurrentList.DefaultShuffleMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case DEFAULT_SHUFFLE_TRACKS:
					getCurrentList().setDefaultTrackShuffleMode( CurrentList.DefaultShuffleMode.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case LASTFM_USERNAME:
					lastFM.setUsername ( value );
					settings.remove ( setting );
					break;
					
				case LASTFM_PASSWORD_MD5:
					lastFM.setPassword ( value );
					settings.remove ( setting );
					break;
					
				case LASTFM_SCROBBLE_ON_PLAY:
					this.lastFMDoScrobble.setValue( Boolean.valueOf( value ) );
					settings.remove ( setting );
					break;
					
				case LASTFM_SCROBBLE_TIME:
					this.lastFMScrobbleTime.setValue( Double.valueOf( value ) );
					settings.remove( setting );
					break;
				}
			} catch ( Exception e ) {
				LOGGER.log( Level.INFO, "Unable to apply setting: " + setting + " to UI.", e );
			}
		});
	}
	
	
//Manage Listeners

	public void addPlayerListener ( PlayerListener listener ) {
		if ( listener != null ) {
			playerListeners.add( listener );
		} else {
			LOGGER.log( Level.INFO, "Null player listener was attempted to be added, ignoring.", new NullPointerException() );
		}
	}
	
	private void notifyListenersPositionChanged ( Track track, int positionMS, int lengthMS ) {
		for ( PlayerListener listener : playerListeners ) {
			listener.playerPositionChanged( positionMS, lengthMS );
		}
		
		if ( !scrobbledThisTrack && lastFMDoScrobble.getValue() ) {
			if ( positionMS / (double)lengthMS >= this.lastFMScrobbleTime.get() ) {
				lastFM.scrobbleTrack( track );
				scrobbledThisTrack = true;
			}
		}
	}
	
	private void notifyListenersStopped ( Track track, StopReason reason ) {
		for ( PlayerListener listener : playerListeners ) {
			listener.playerStopped( track, reason );
		}
		
		if ( !scrobbledThisTrack && lastFMDoScrobble.getValue() && reason == StopReason.TRACK_FINISHED && track != null ) {
			lastFM.scrobbleTrack( track );
			scrobbledThisTrack = true;
		}
	}
	
	private void notifyListenersStarted ( Track track ) {
		scrobbledThisTrack = false;
		for ( PlayerListener listener : playerListeners ) {
			listener.playerStarted( track );
		}
	}
	
	private void notifyListenersPaused () {
		for ( PlayerListener listener : playerListeners ) {
			listener.playerPaused();
		}
	}
	
	private void notifyListenersUnpaused () {
		for ( PlayerListener listener : playerListeners ) {
			listener.playerUnpaused( );
		}
	}
	
	private void notifyListenersVolumeChanged ( double newVolumePercent ) {
		for ( PlayerListener listener : playerListeners ) {
			listener.playerVolumeChanged( newVolumePercent );
		}
	}
	
	private void notifyListenersShuffleModeChanged ( ShuffleMode newMode ) {
		for ( PlayerListener listener : playerListeners ) {
			listener.playerShuffleModeChanged( newMode );
		}
	}
	
	private void notifyListenersRepeatModeChanged ( RepeatMode newMode ) {
		for ( PlayerListener listener : playerListeners ) {
			listener.playerRepeatModeChanged( newMode );
		}
	}
	
	//REFACTOR: Make these a listener interface, and add this object as a listener to player? 	
	
	private int consecutiveFailedToStartCount = 0;
	void playerStopped ( Track track, StopReason reason ) { 
		if ( reason == StopReason.TRACK_FINISHED ) {
			next ( false );
			consecutiveFailedToStartCount = 0;
			
		} else if ( reason == StopReason.UNABLE_TO_START_TRACK ) {

			consecutiveFailedToStartCount++;
			
			if ( consecutiveFailedToStartCount <= currentList.getSortedItems().size() ) {
				next ( false );
			} 
		}
		
		notifyListenersStopped ( track, reason );
	}

	void playerPaused () {
		notifyListenersPaused();
	}

	void playerUnpaused () {
		notifyListenersUnpaused();
	}

	void volumeChanged ( double volumePercentRequested ) {
		notifyListenersVolumeChanged ( volumePercentRequested );
	}

	void playerStarted ( Track track ) {
		notifyListenersStarted( track );
	}

	void playerTrackPositionChanged ( Track track, int positionMS, int lengthMS ) {
		notifyListenersPositionChanged ( track, positionMS, lengthMS );
	}
	
	public void playTrack ( Track track ) {
		playTrack ( track, false );
	}
	
	public void playTrack ( Track track, boolean startPaused ) {
		playTrack ( track, startPaused, true );
	}
	                                                                 
	public void playTrack ( Track track, boolean startPaused, boolean addToPreviousNextStack ) {

		player.requestPlayTrack( track, startPaused );
		for ( CurrentListTrack listTrack : currentList.getItems() ) {
			listTrack.setIsCurrentTrack( false );
		}
		
		if ( track instanceof CurrentListTrack ) {
			for ( CurrentListTrack listTrack : currentList.getItems() ) {
				listTrack.setIsLastCurrentListTrack( false );
			}
			
			((CurrentListTrack)track).setIsCurrentTrack( true );
			((CurrentListTrack)track).setIsLastCurrentListTrack( true );
		}
		
		if ( addToPreviousNextStack ) {
			previousStack.addToStack ( track );
		}
		
		history.trackPlayed( track );
	}
	

	public void playItems ( List <Track> items ) {
		//REFACTOR: maybe break this into two separate functions and have the UI determine whether to set tracks or just play
		if ( items.size() == 1 ) {
			playTrack ( items.get( 0 ) );
		} else if ( items.size() > 1 ) {
			currentList.setTracks( items );
			next ( false );
		}
	}

	//Used after program loads to get everything linked back up properly. 
	public void linkQueueToCurrentList () {
		for ( CurrentListTrack track : currentList.getItems() ) {
			for ( int index : track.getQueueIndices() ) {
				if ( index < queue.size() ) {
					queue.getData().set( index - 1, track );
				} else {
					LOGGER.log( Level.INFO, "Current list had a queue index beyond the length of the queue. Removing.", new Exception() );
					track.getQueueIndices().remove( Integer.valueOf ( index ) );
				}
			}
		}
	}

	public void toggleMute () {
		if ( player.getVolumePercent() == 0 ) {
			if ( unmutedVolume != null ) {
				player.requestVolumePercent( unmutedVolume );
				unmutedVolume = null;
			} else { 
				player.requestVolumePercent( 1 );
			}
		} else {
			unmutedVolume = player.getVolumePercent();
			player.requestVolumePercent( 0 );
		}
	}

	public LastFM getLastFM () {
		return lastFM;
	}
	
	public BooleanProperty doLastFMScrobbleProperty() {
		return lastFMDoScrobble;
	}

	public boolean doLastFMScrobble() {
		return lastFMDoScrobble.getValue();
	}
	
	public void setScrobbleTime( double value ) {
		lastFMScrobbleTime.set( value );
	}

	public DoubleProperty scrobbleTimeProperty () {
		return lastFMScrobbleTime;
	}

	public FXUI getUI () {
		return ui;
	}
} public interface PlayerListener {
	public void playerPositionChanged ( int positionMS, int lengthMS );
	public void playerStopped ( Track track, StopReason reason );
	public void playerStarted ( Track track );
	public void playerPaused ();
	public void playerUnpaused (); 
	public void playerVolumeChanged ( double newVolumePercent );
	public void playerShuffleModeChanged ( ShuffleMode newMode );
	public void playerRepeatModeChanged ( RepeatMode newMode );
} public enum RepeatMode {
	PLAY_ONCE ( "⇥" ), REPEAT ( "🔁" ), REPEAT_ONE_TRACK ( "🔂" );
	String symbol;
	RepeatMode ( String symbol ) { this.symbol = symbol; }
	public String getSymbol () { return symbol; }
} public enum ShuffleMode {
	SEQUENTIAL ( "⇉" ), SHUFFLE ( "🔀" );
	String symbol;
	ShuffleMode ( String symbol ) { this.symbol = symbol; }
	public String getSymbol () { return symbol; }
} public enum StopReason {
	TRACK_FINISHED,
	USER_REQUESTED,
	END_OF_CURRENT_LIST,
	EMPTY_LIST,
	WRITING_TO_TAG,
	UNABLE_TO_START_TRACK,
	ERROR
} public class GlobalHotkeys {
	private static transient final Logger LOGGER = Logger.getLogger( GlobalHotkeys.class.getName() );
	
	public enum Hotkey {
		PREVIOUS ( "Previous" ), 
		NEXT ( "Next" ), 
		PLAY_PAUSE ( "Play / Pause" ),
		PLAY ( "Play" ), 
		STOP ( "Stop" ),
		
		VOLUME_UP ( "Volume Up" ),
		VOLUME_DOWN ( "Volume Down" ),
		TOGGLE_MUTE ( "Mute / Unmute" ),
		
		TOGGLE_SHUFFLE ( "Toggle Shuffle" ),
		TOGGLE_REPEAT ( "Toggle Repeat" ),
		
		SHOW_HIDE_UI ( "Show / Hide Main Window" ),
		
		SKIP_FORWARD ( "Forward 5 Seconds" ),
		SKIP_BACK ( "Back 5 Seconds" );
		
		private String label;
		Hotkey ( String label ) { this.label = label; }
		public String getLabel () { return label; }
	}

	List<GlobalHotkeyListener> listeners = new ArrayList<>();

	Map <Hotkey, HotkeyState> hotkeyMap = new EnumMap <> ( Hotkey.class );
	
	private boolean disabled = false;
	boolean hasUnsavedData = false;
	private boolean inEditMode = false;
	
	private SystemHotkeys system;
	
	public GlobalHotkeys( OS operatingSystem, boolean disabled ) {
		this.disabled = disabled;
		this.hasUnsavedData = true;
		try {
			switch ( operatingSystem ) {
				case NIX:
					system = new LinuxGlobalHotkeys( this );
					break;
				case OSX:
					throw new HotkeyException ( "OSX does not currently support hotkeys." );
				case WIN_10:
				case WIN_7:
				case WIN_8:
				case WIN_UNKNOWN:
				case WIN_VISTA:
					system = new WindowsGlobalHotkeys( this );
					break;
				case WIN_XP:
					throw new HotkeyException ( "Windows XP not supported by Hypnos." );
				case UNKNOWN:
				default:
					throw new HotkeyException ( "Cannot recognize operating system." );
				
			}
		} catch ( HotkeyException e ) {
			system = new DummyGlobalHotkeys ( this );
			disabled = true;
			LOGGER.log( Level.WARNING, "Unable to setup global hotkeys, they will be disabled for this session." + e );
		}
	}
	
	public void addListener ( GlobalHotkeyListener listener ) {
		listeners.add ( listener );
	}
	
	public boolean hasUnsavedData () {
		return hasUnsavedData;
	}

	public void setHasUnsavedData ( boolean b ) {
		hasUnsavedData = b;
	}
	
	public boolean isDisabled() {
		return disabled || system instanceof DummyGlobalHotkeys;
	}
	
	public String getReasonDisabled() {
		if ( disabled ) {
			return "Disabled at launch";
		} else if ( system instanceof DummyGlobalHotkeys ) {
			return "Global Hotkeys not currently supported by Hypnos for your Operating System.";
		} else {
			return "";
		}
	}
	
	public boolean registerFXHotkey ( Hotkey hotkey, KeyEvent event ) {
		if ( isDisabled() ) return false;
		return registerHotkey ( hotkey, new HotkeyState ( event ) );
	}

	public boolean registerHotkey ( Hotkey hotkey, HotkeyState keystate ) {
		if ( isDisabled() ) return false;
		if ( keystate == null ) return false;
		
		for ( Hotkey existingKey : hotkeyMap.keySet() ) {
			if ( keystate.equals( hotkeyMap.get( existingKey ) ) ) {
				clearHotkey ( existingKey );
			}
		}
		
		boolean result = system.registerHotkey ( hotkey, keystate );
		if ( inEditMode ) {
			system.unregisterHotkey ( hotkey );
		}
		
		if ( result ) {
			hotkeyMap.put( hotkey, keystate );
			hasUnsavedData = true;
		} 
		
		return result;
	}
	
	public void beginEditMode() {
		if ( isDisabled() ) return;
		if ( !inEditMode ) {
			for ( Hotkey hotkey : hotkeyMap.keySet() ) {
				system.unregisterHotkey ( hotkey );
			}
			inEditMode = true;
		}
	}
	
	public void endEditMode() {
		if ( isDisabled() ) return;
		if ( inEditMode ) {
			for ( Hotkey hotkey : hotkeyMap.keySet() ) {
				system.registerHotkey ( hotkey, hotkeyMap.get( hotkey ) );
			}
			inEditMode = false;
		}
	}
	
	public void prepareToExit() {
		for ( Hotkey hotkey : hotkeyMap.keySet() ) {
			system.unregisterHotkey ( hotkey );
		}
	}
	
	public void clearAllHotkeys() {
		if ( isDisabled() ) return;
		for ( Hotkey hotkey : hotkeyMap.keySet() ) {
			clearHotkey ( hotkey );
		}
	}
	
	public void clearHotkey ( Hotkey key ) {
		if ( isDisabled() ) return;
		if ( key == null ) return;
		hotkeyMap.remove( key );
		hasUnsavedData = true;
		system.unregisterHotkey ( key );
	}

	public Map <Hotkey, HotkeyState> getMap() {
		return hotkeyMap;
	}
	
	public void setMap ( EnumMap <Hotkey, HotkeyState> map ) {
		this.hotkeyMap = map;

		if ( isDisabled() ) return;
		for ( Hotkey hotkey : map.keySet() ) {
			registerHotkey ( hotkey, map.get( hotkey ) );
		}
	}

	public String getDisplayText ( Hotkey hotkey ) {
		HotkeyState state = hotkeyMap.get( hotkey );
		
		if ( state == null ) {
			return "";
		} else {
			return hotkeyMap.get( hotkey ).getDisplayText( );
		}
	}

	void systemHotkeyEventHappened ( Hotkey hotkey ) {
		if ( isDisabled() || inEditMode ) return;
		for ( GlobalHotkeyListener listener : listeners ) {
			listener.hotkeyPressed ( hotkey );
		}
	}

	public HotkeyState createJustPressedState ( KeyEvent keyEvent ) {
		return system.createJustPressedState ( keyEvent );
	}
} public class Album implements Serializable, AlbumInfoSource {
	private static transient final Logger LOGGER = Logger.getLogger(Album.class.getName());

	private static final long serialVersionUID = 2L;
	private File directory;

	private transient ObservableList<Track> tracks;
	private transient StringProperty albumArtistProperty;
	private transient StringProperty yearProperty;
	private transient StringProperty albumTitleProperty;
	private transient StringProperty fullAlbumTitleProperty;
	private transient StringProperty dateAddedStringProperty;
	private transient IntegerProperty discNumberProperty;
	private transient IntegerProperty discCountProperty;
	private transient StringProperty releaseTypeProperty;
	private transient StringProperty discSubtitleProperty;

	private void initializeTransientFields() {
		tracks = FXCollections.observableArrayList();
		this.tracks.addListener(new InvalidationListener() {
			@Override
			public void invalidated(Observable arg0) {
				updateData();
			}
		});
		albumArtistProperty = new SimpleStringProperty("");
		yearProperty = new SimpleStringProperty("");
		albumTitleProperty = new SimpleStringProperty("");
		fullAlbumTitleProperty = new SimpleStringProperty("");
		dateAddedStringProperty = new SimpleStringProperty("");
		discNumberProperty = new SimpleIntegerProperty();
		discCountProperty = new SimpleIntegerProperty();
		releaseTypeProperty = new SimpleStringProperty("");
		discSubtitleProperty = new SimpleStringProperty("");
	}

	public Album(Path albumDirectory, List<Track> tracks) {
		initializeTransientFields();
		this.directory = albumDirectory.toFile();
		setTracks(tracks);
	}

	public void setTracks(List<Track> tracks) {
		this.tracks.setAll(tracks);
		for (Track track : tracks) {
			track.setAlbum(this);
		}
	}

	public void updateData() {
		albumArtistProperty.set(tracks.get(0).getAlbumArtist());
		if (tracks.get(0).getYear().length() > 4) {
			yearProperty.set(tracks.get(0).getYear().substring(0, 4));
		} else {
			yearProperty.set(tracks.get(0).getYear());
		}
		albumTitleProperty.set(tracks.get(0).getAlbumTitle());
		fullAlbumTitleProperty.set(tracks.get(0).getFullAlbumTitle());
		discNumberProperty.set(tracks.get(0).getDiscNumber());
		discCountProperty.set(tracks.get(0).getDiscCount());
		releaseTypeProperty.set(tracks.get(0).getReleaseType());
		discSubtitleProperty.set(tracks.get(0).getDiscSubtitle());
		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
		try {
			long creationMS = Files.readAttributes(directory.toPath(), BasicFileAttributes.class).creationTime().toMillis();
			dateAddedStringProperty.setValue(sdf.format(new Date(creationMS)));
		} catch (IOException e) {
			dateAddedStringProperty.setValue(sdf.format(new Date()));
			LOGGER.log(Level.INFO, "Unable to determine file creation time for album, assuming it is very old." + directory.toString(), e);
		}
	}

	public StringProperty getAlbumArtistProperty() {
		return albumArtistProperty;
	}

	public StringProperty getYearProperty() {
		return yearProperty;
	}

	public StringProperty getAlbumTitleProperty() {
		return albumTitleProperty;
	}

	public StringProperty getFullAlbumTitleProperty() {
		return fullAlbumTitleProperty;
	}

	public StringProperty getDateAddedStringProperty() {
		return dateAddedStringProperty;
	}

	public IntegerProperty getDiscNumberProperty() {
		return discNumberProperty;
	}

	public IntegerProperty getDiscCountProperty() {
		return discCountProperty;
	}

	public StringProperty getReleaseTypeProperty() {
		return releaseTypeProperty;
	}

	public StringProperty getDiscSubtitleProperty() {
		return discSubtitleProperty;
	}

	public String getAlbumArtist() {
		return albumArtistProperty.get();
	}

	public String getYear() {
		return yearProperty.get();
	}

	public String getAlbumTitle() {
		return albumTitleProperty.get();
	}

	public String getFullAlbumTitle() {
		return fullAlbumTitleProperty.get();
	}

	public Integer getDiscNumber() {
		return discNumberProperty.get();
	}

	public Integer getDiscCount() {
		return discCountProperty.get();
	}

	public String getReleaseType() {
		return releaseTypeProperty.get();
	}

	public String getDiscSubtitle() {
		return discSubtitleProperty.get();
	}

	public Path getPath() {
		return directory.toPath();
	}

	public ObservableList<Track> getTracks() {
		return tracks;
	}

	@Override
	public boolean equals(Object e) {
		if (!(e instanceof Album)) {
			return false;
		}
		Album compareTo = (Album) e;
		return compareTo.getPath().toAbsolutePath().equals(this.getPath().toAbsolutePath());
	}

	public Image getAlbumCoverImage() {
		for (Track track : tracks) {
			if (track.getAlbumCoverImage() != null) {
				return track.getAlbumCoverImage();
			}
		}
		return null;
	}

	public Image getAlbumArtistImage() {
		for (Track track : tracks) {
			if (track.getAlbumCoverImage() != null) {
				return track.getArtistImage();
			}
		}
		return null;
	}
	
	private void writeObject ( ObjectOutputStream out ) throws IOException {
		out.defaultWriteObject();
		out.writeObject(new ArrayList<Track>(tracks));
	}
	
	private void readObject ( ObjectInputStream in ) throws IOException, ClassNotFoundException {
		initializeTransientFields();
		in.defaultReadObject();
		tracks.addAll((ArrayList<Track>)in.readObject());
		updateData();
	}
}
 public class Artist implements Serializable {
	private static final long serialVersionUID = 1L;

	//This values are only used and accurate during serialization. Don't read and write to them. 
	private List <Album> albumsForSerialization = new ArrayList<>();
	private List <Track> looseTracksForSerialization = new ArrayList<>();
	
	private transient ObservableList <Album> albums = FXCollections.observableArrayList();
	private transient ObservableList <Track> looseTracks = FXCollections.observableArrayList();
	private transient StringProperty name = new SimpleStringProperty("");
	
	private transient IntegerProperty trackCount = new SimpleIntegerProperty(); 
	{	
		//This is a hack because Track and Album don't use Observable Values
		albums.addListener( ( Observable obs ) -> trackCount.set( getAllTracks().size() ) );
		looseTracks.addListener( ( Observable obs ) -> trackCount.set( getAllTracks().size() ) );
	};
	
	private transient IntegerProperty albumCount = new SimpleIntegerProperty(); 
	{
		albumCount.bind( Bindings.size( albums ) );
	}
	
	private transient IntegerProperty totalLength = new SimpleIntegerProperty ( 0 );
	{	
		//This is a hack because Track and Album don't use Observable Values
		InvalidationListener listener = ( Observable obs ) -> recalculateTotalLength();
		albums.addListener( listener );
		looseTracks.addListener( listener );
	};
	
	private void recalculateTotalLength () {
		int lengthS = 0;
		for ( Track track : getAllTracks() ) {
			lengthS += track.getLengthS();
		}
		totalLength.setValue( lengthS );
	}
	
	public Artist ( String name ) {
		this.name.set(name);
	}
	
	public Artist ( String name, List<Album> albums, List<Track> looseTracks ) {
		this.name.set(name);
		albums.addAll( albums );
		looseTracks.addAll( looseTracks );
	}
	
	public String getName() {
		return name.get();
	}
	
	public StringProperty nameProperty() {
		return name;
	}
	
	public IntegerProperty lengthProperty() {
		return totalLength;
	}
	
	public IntegerProperty albumCountProperty() {
		return albumCount;
	}
		
	public IntegerProperty trackCountProperty() {
		return trackCount;
	}
	
	public int getTrackCount() {
		return trackCount.get();
	}
	
	public List<Track> getAllTracks() {
		List<Track> retMe = new ArrayList<>();
		for ( Album album : albums ) {
			retMe.addAll ( album.getTracks() );
		}
		retMe.addAll( looseTracks );
		return retMe;
	}
	
	public boolean equals ( Object object ) {
		if ( ! ( object instanceof Artist ) ) {
			return false;
		}
		
		return ( ((Artist)object).getName().equals ( getName() ) );
	}

	public void addAlbum ( Album album ) {
		if ( albums.contains( album ) ) {
			albums.remove( album );
		}
		
		//TODO: error checking maybe? 
		albums.add ( album );
	}

	public void addLooseTrack ( Track track ) {
		looseTracks.add ( track );
	}

	public void removeAlbum ( Album removeMe ) {
		albums.remove( removeMe );
	}

	public void removeTrack ( Track removeMe ) {
		looseTracks.remove ( removeMe );
	}

	public List <Album> getAlbums () {
		return new ArrayList<Album> ( albums );
	}
	
	private void writeObject ( ObjectOutputStream out ) throws IOException {
		albumsForSerialization.clear();
		albumsForSerialization.addAll( albums );
		looseTracksForSerialization.clear();
		looseTracksForSerialization.addAll( looseTracks );
		out.defaultWriteObject();
	}
	
	private void readObject ( ObjectInputStream in ) throws IOException, ClassNotFoundException {
		in.defaultReadObject();
		
		trackCount = new SimpleIntegerProperty(); 
		albumCount = new SimpleIntegerProperty(); 
		totalLength = new SimpleIntegerProperty();
		name = new SimpleStringProperty();

		albums = FXCollections.observableArrayList( albumsForSerialization );
		looseTracks = FXCollections.observableArrayList( looseTracksForSerialization );
		
		albumCount.set( albums.size() );
		trackCount.set( getAllTracks().size() );
		recalculateTotalLength();
	}
} public class Library {
	private static final Logger LOGGER = Logger.getLogger(Library.class.getName());

	public enum LoaderSpeed {
		LOW, MED, HIGH
	}

	// These are all three representations of the same data. Add stuff to the
	// Observable List, the other two can't accept add.
	private final CachedList<Track> tracks = new CachedList<>();
	private final FilteredList<Track> tracksFiltered = new FilteredList<>(tracks.getDisplayItems(), p -> true);
	private final SortedList<Track> tracksSorted = new SortedList<>(tracksFiltered);

	private final CachedList<Album> albums = new CachedList<>();
	private final FilteredList<Album> albumsFiltered = new FilteredList<>(albums.getDisplayItems(), p -> true);
	private final SortedList<Album> albumsSorted = new SortedList<>(albumsFiltered);

	private final CachedList<Artist> artists = new CachedList<>();
	private final FilteredList<Artist> artistsFiltered = new FilteredList<>(artists.getDisplayItems(), p -> true);
	private final SortedList<Artist> artistsSorted = new SortedList<>(artistsFiltered);

	private final CachedList<Playlist> playlists = new CachedList<>();
	private final FilteredList<Playlist> playlistsFiltered = new FilteredList<>(playlists.getDisplayItems(), p -> true);
	private final SortedList<Playlist> playlistsSorted = new SortedList<>(playlistsFiltered);

	private final CachedList<TagError> tagErrors = new CachedList<>();
	private final FilteredList<TagError> tagErrorsFiltered = new FilteredList<>(tagErrors.getDisplayItems(), p -> true);
	private final SortedList<TagError> tagErrorsSorted = new SortedList<>(tagErrorsFiltered);

	private final CachedList<MusicRoot> musicRoots = new CachedList<>();

	private AudioSystem audioSystem;
	private final LibraryLoader loader;
	private final DiskWatcher diskWatcher;
	private final LibraryScanLogger scanLogger = new LibraryScanLogger();
	
	private boolean dataNeedsToBeSavedToDisk = false;
	private boolean upkeepNeeded = false;
	private final Object doUpkeepFlagLock = new Object();

	public Library() {
		diskWatcher = new DiskWatcher(this, scanLogger);
		loader = new LibraryLoader(this, scanLogger);
		InvalidationListener invalidationListener = new InvalidationListener() {
			@Override
			public void invalidated(Observable arg0) {
				dataNeedsToBeSavedToDisk = true;
				synchronized (doUpkeepFlagLock) {
					upkeepNeeded = true;
				}
			}
		};
		tracks.addListenerToBase(invalidationListener);
		albums.addListenerToBase(invalidationListener);
		Thread artistGeneratorThread = new Thread() {
			@Override
			public void run() {
				while (true) {
					boolean doUpkeep = false;
					synchronized (doUpkeepFlagLock) {
						doUpkeep = upkeepNeeded;
						upkeepNeeded = false;
					}
					if (doUpkeep) {
						scanLogger.println("[Library] Changes to library were made, regenerating artist list");
						artists.getItemsCopy().setAll(generateArtists());
						relinkPlaylistsToLibrary();
						relinkCurrentListToLibrary();
					}
					try {
						Thread.sleep(2000);
					} catch (InterruptedException e) {
						LOGGER.log(Level.INFO, "Sleep interupted during wait period.", e);
					}
				}
			}
		};
		artistGeneratorThread.setName("Artist Generator");
		artistGeneratorThread.setDaemon(true);
		artistGeneratorThread.start();
	}

	public void setUI(FXUI ui) {
		loader.setUI(ui);
		diskWatcher.setUI(ui);
	}

	public boolean dataNeedsToBeSavedToDisk() {
		return dataNeedsToBeSavedToDisk;
	}

	public SortedList<Playlist> getPlaylistsSorted() {
		return playlistsSorted;
	}

	public ObservableList<Playlist> getPlaylistData() {
		return playlists.getItemsCopy();
	}

	public ObservableList<Playlist> getPlaylistsDisplayCache() {
		return playlists.getDisplayItems();
	}

	public ObservableList<MusicRoot> getMusicRootData() {
		return musicRoots.getItemsCopy();
	}

	public ObservableList<MusicRoot> getMusicRootDisplayCache() {
		return musicRoots.getDisplayItems();
	}

	public FilteredList<Playlist> getPlaylistsFiltered() {
		return playlistsFiltered;
	}

	public SortedList<Album> getAlbumsSorted() {
		return albumsSorted;
	}

	public FilteredList<Album> getAlbumsFiltered() {
		return albumsFiltered;
	}

	public ObservableList<Track> getTrackData() {
		return tracks.getItemsCopy();
	}

	public ObservableList<Track> getTrackDisplayCache() {
		return tracks.getDisplayItems();
	}

	public ObservableList<Album> getAlbumData() {
		return albums.getItemsCopy();
	}

	public ObservableList<Album> getAlbumDisplayCache() {
		return albums.getDisplayItems();
	}

	public ObservableList<Artist> getArtistData() {
		return artists.getItemsCopy();
	}

	public ObservableList<Artist> getArtistDisplayCache() {
		return artists.getDisplayItems();
	}

	public SortedList<Track> getTracksSorted() {
		return tracksSorted;
	}

	public FilteredList<Track> getTracksFiltered() {
		return tracksFiltered;
	}

	public SortedList<Artist> getArtistsSorted() {
		return artistsSorted;
	}

	public FilteredList<Artist> getArtistsFiltered() {
		return artistsFiltered;
	}

	public SortedList<TagError> getTagErrorsSorted() {
		return tagErrorsSorted;
	}

	public void requestRescan(Path path) {
		loader.queueUpdatePath(path);
	}
	
	public void requestRescan(List<Album> albums) {
		for (Album album : albums) {
			requestRescan(album.getPath());
		}
	}

	public void setMusicRootsOnInitialLoad(List<MusicRoot> roots) {
		for (MusicRoot musicRoot : roots) {
			musicRoot.setNeedsRescan(true);
		}
		musicRoots.getItemsCopy().setAll(roots);
	}
	
	public void setDataOnInitialLoad(List<Playlist> playlists) {
		this.playlists.setDataOnInitialLoad(playlists);
	}

	public void setDataOnInitialLoad(List<Track> tracks, List<Album> albums) {
		this.tracks.setDataOnInitialLoad(tracks);
		this.albums.setDataOnInitialLoad(albums);
		this.artists.setDataOnInitialLoad(generateArtists());
		List<TagError> errors = new ArrayList<>();
		for(Track track : tracks) {
			errors.addAll(track.getTagErrors());
		}
		this.tagErrors.setDataOnInitialLoad(errors);
	}

	public void addMusicRoot(Path path) {
		for (MusicRoot root : musicRoots.getItemsCopy()) {
			if(root.getPath().equals(path)) {
				return;
			}
		}
		musicRoots.addItem(new MusicRoot(path), true);
	}

	public void removeMusicRoot(MusicRoot musicRoot) {
		loader.interruptDiskReader();
		musicRoots.remove(musicRoot, true);
		loader.setMusicRootRemoved(true);
	}

	public void removePlaylist(Playlist playlist) {
		playlists.remove(playlist, true);
	}

	public void addPlaylist(Playlist playlist) {
		playlists.addItem(playlist, true);
	}

	public String getUniquePlaylistName() {
		return getUniquePlaylistName("New Playlist");
	}

	public void startThreads() {
		loader.start();
	}

	public LibraryScanLogger getScanLogger() {
		return scanLogger;
	}

	public String getUniquePlaylistName(String base) {
		String name = base;
		int number = 0;
		while (true) {
			boolean foundMatch = false;
			for (Playlist playlist : playlists.getItemsCopy()) {
				if (playlist.getName().toLowerCase().equals(name.toLowerCase())) {
					foundMatch = true;
				}
			}
			if (foundMatch) {
				number++;
				name = base + " " + number;
			} else {
				break;
			}
		}
		return name;
	}

	private List<Artist> generateArtists() {
		List<Artist> newArtistList = new ArrayList<>();
		List<Album> libraryAlbums = new ArrayList<>(albums.getItemsCopy());
		Album[] albumArray = libraryAlbums.toArray(new Album[libraryAlbums.size()]);
		AlphanumComparator comparator = new AlphanumComparator(AlphanumComparator.CaseHandling.CASE_INSENSITIVE);
		Arrays.sort(albumArray, Comparator.comparing(Album::getAlbumArtist, comparator));
		Artist lastArtist = null;
		for (Album album : albumArray) {
			if (album.getAlbumArtist().isBlank()) {
				continue;
			}
			if (lastArtist != null && lastArtist.getName().equals(album.getAlbumArtist())) {
				lastArtist.addAlbum(album);
			} else {
				Artist artist = null;
				for (Artist test : newArtistList) {
					if (test.getName().equalsIgnoreCase(album.getAlbumArtist())) {
						artist = test;
						break;
					}
				}
				if (artist == null) {
					artist = new Artist(album.getAlbumArtist());
					newArtistList.add(artist);
				}
				artist.addAlbum(album);
				lastArtist = artist;
			}
		}
		List<Track> looseTracks = new ArrayList<>();
		for (Track track : tracks.getItemsCopy()) {
			if (track.getAlbum() == null) {
				looseTracks.add(track);
			}
		}
		Track[] trackArray = looseTracks.toArray(new Track[looseTracks.size()]);
		Arrays.sort(trackArray, Comparator.comparing(Track::getAlbumArtist, comparator));
		lastArtist = null;
		for (Track track : trackArray) {
			if (track.getAlbumArtist().isBlank())
				continue;
			if (lastArtist != null && lastArtist.getName().equals(track.getAlbumArtist())) {
				lastArtist.addLooseTrack(track);
			} else {
				Artist artist = null;
				for (Artist test : newArtistList) {
					if (test.getName().equalsIgnoreCase(track.getAlbumArtist())) {
						artist = test;
						break;
					}
				}
				if (artist == null) {
					artist = new Artist(track.getAlbumArtist());
					newArtistList.add(artist);
				}
				artist.addLooseTrack(track);
				lastArtist = artist;
			}
		}
		return newArtistList;
	}

	LibraryLoader getLoader() {
		return loader;
	}

	DiskWatcher getDiskWatcher() {
		return diskWatcher;
	}

	public void setDataNeedsToBeSavedToDisk(boolean b) {
		this.dataNeedsToBeSavedToDisk = b;
	}

	void addTrack(Track track) {
		tracks.addItem(track);
		for (TagError error : track.getTagErrors()) {
			tagErrors.addItem(error);
		}
	}

	void removeTrack(Track track) {
		tracks.remove(track);
		for (TagError error : track.getTagErrors()) {
			tagErrors.remove(error);
		}
	}

	void addAlbum(Album album) {
		albums.addItem(album);
	}

	void notAnAlbum(Path path) {
		for (Album album : albums.getItemsCopy()) {
			if (album.getPath().equals(path)) {
				albums.remove(album);
				break;
			}
		}
	}

	void removeAlbum(Album album) {
		albums.remove(album);
	}
	
	void requestRegenerateArtists() {
		upkeepNeeded = true;
	}
	
	public boolean isArtistDirectory(Path path) {
		String directoryName = Utils.prepareArtistForCompare(path.getFileName().toString());
		List<Album> albumsInPath = new ArrayList<>();
		for (Album album : albums.getItemsCopy()) {
			if (album.getPath().getParent().equals(path)) {
				albumsInPath.add(album);
			}
		}
		List<Track> tracksInPath = new ArrayList<>();
		for (Track track : tracks.getItemsCopy()) {
			if (track.getPath().getParent().equals(path) && track.getAlbum() == null) {
				tracksInPath.add(track);
			}
		}
		if(albumsInPath.size() == 0 && tracksInPath.size() == 0) {
			return false;
		}
		for (Album album : albumsInPath) {
			try {
				int matchPercent = FuzzySearch.weightedRatio(directoryName, Utils.prepareArtistForCompare(album.getAlbumArtist()));
				if (matchPercent < 90) {
					return false;
				}
			} catch (Exception e) {
				continue;
			}
		}
		for (Track track : tracksInPath) {
			try {
				int matchPercent = FuzzySearch.weightedRatio(directoryName, Utils.prepareArtistForCompare(track.getAlbumArtist()));
				if (matchPercent < 90) {
					return false;
				}
			} catch (Exception e) {
				continue;
			}
		}
		return true;
	}
	
	private void relinkCurrentListToLibrary() {
		for (int k = 0; k < audioSystem.getCurrentList().getItems().size(); k++ ) {
			boolean foundInLibrary = false;
			for (Track libraryTrack : tracks.getItemsCopy()) {
				if (libraryTrack.equals(audioSystem.getCurrentList().getItems().get(k))) {
					audioSystem.getCurrentList().getItems().get(k).setData(libraryTrack);
					foundInLibrary = true;
				}
			}
			if(!foundInLibrary) {
				audioSystem.getCurrentList().getItems().get(k).setAlbum(null);
			}
		}
	}
	
	private void relinkPlaylistsToLibrary() {
		for (Playlist playlist : playlists.getItemsCopy()) {
			linkPlaylistToLibrary(playlist);
		}
	}

	public void linkPlaylistToLibrary(Playlist playlist) {
		for (int k = 0; k < playlist.getTracks().size(); k++ ) {
			boolean foundInLibrary = false;
			for (Track libraryTrack : tracks.getItemsCopy()) {
				if (libraryTrack.equals(playlist.getTracks().get(k))) {
					playlist.getTracks().set(k, libraryTrack);
					foundInLibrary = true;
				}
			}
			if(!foundInLibrary) {
				playlist.getTracks().get(k).setAlbum(null);
			}
		}
	}

	public void setAudioSystem(AudioSystem audioSystem) {
		this.audioSystem = audioSystem;
	}

	public void removeTagErrors(Vector<TagError> errors) {
		for (TagError error : errors) {
			tagErrors.remove(error);
		}
	}
	
	public void addTagErrors(Vector<TagError> errors) {
		for (TagError error : errors) {
			tagErrors.addItem(error);
		}
	}
}
 public class Playlist implements Serializable {
	private static final Logger LOGGER = Logger.getLogger(Playlist.class.getName());
	private static final long serialVersionUID = 1L;

	public enum PlaylistShuffleMode {
		USE_DEFAULT, SEQUENTIAL, SHUFFLE,
	}

	public enum PlaylistRepeatMode {
		USE_DEFAULT, PLAY_ONCE, REPEAT
	}

	private transient ObservableList<Track> tracks = FXCollections.observableArrayList();
	private transient boolean hasUnsavedData = true;
	private String name;
	private PlaylistShuffleMode shuffleMode = PlaylistShuffleMode.USE_DEFAULT;
	private PlaylistRepeatMode repeatMode = PlaylistRepeatMode.USE_DEFAULT;

	public Playlist(String name) {
		this(name, new ArrayList<Track>());
		tracks.addListener((ListChangeListener.Change<? extends Track> change) -> {
			hasUnsavedData = true;
		});
	}

	@Override
	public boolean equals(Object test) {
		if (test == null) {
			return false;
		}
		if (!(test instanceof Playlist)) {
			return false;
		}
		Playlist testPlaylist = (Playlist) test;
		return testPlaylist.name.equals(name);
	}

	public boolean hasUnsavedData() {
		return hasUnsavedData;
	}

	public void setHasUnsavedData(boolean b) {
		hasUnsavedData = b;
	}

	public Playlist(String name, List<Track> tracks) {
		if (name == null || name.length() == 0) {
			name = Hypnos.getLibrary().getUniquePlaylistName();
		}
		setTracks(tracks);
		this.name = name;
	}

	public static List<Path> getTrackPaths(Path playlistPath) {
		List<Path> retMe = new ArrayList<Path>();
		if (playlistPath.toString().toLowerCase().endsWith(".m3u")) {
			try (FileInputStream fileInput = new FileInputStream(playlistPath.toFile());) {
				BufferedReader m3uIn = new BufferedReader(new InputStreamReader(fileInput, "UTF8"));
				for (String line; (line = m3uIn.readLine()) != null;) {
					if (line.isEmpty()) {
						// Do nothing
					} else if (!line.startsWith("#")) {
						retMe.add(Paths.get(line));
					}
				}
			} catch (Exception e) {
				LOGGER.log(Level.INFO, "Error reading playlist file: " + playlistPath, e);
			}
		} else {
			LOGGER.log(Level.INFO, "Asked to load a playlist that doesn't have a playlist extension, ignoring.", new Exception());
		}
		return retMe;
	}

	public static Playlist loadPlaylist(Path path) {
		if (path.toString().toLowerCase().endsWith(".m3u")) {
			Playlist playlist = new Playlist(path.getFileName().toString());
			try (FileInputStream fileInput = new FileInputStream(path.toFile());) {
				BufferedReader m3uIn = new BufferedReader(new InputStreamReader(fileInput, "UTF8"));
				for (String line; (line = m3uIn.readLine()) != null; /**/ ) {
					try {
						if (line.startsWith("#Name:")) {
							String name = line.split(":")[1].trim();
							playlist.setName(name);
						} else if (line.startsWith("#RepeatMode:")) {
							PlaylistRepeatMode repeatMode = PlaylistRepeatMode.valueOf(line.split(":")[1].trim());
							playlist.setRepeatMode(repeatMode);
						} else if (line.startsWith("#ShuffleMode:")) {
							PlaylistShuffleMode repeatMode = PlaylistShuffleMode.valueOf(line.split(":")[1].trim());
							playlist.setShuffleMode(repeatMode);
						} else if (line.isEmpty()) {
							// Do nothing
						} else if (!line.startsWith("#")) {
							playlist.addTrack(new Track(Paths.get(line)));
						}
					} catch (Exception e) {
						LOGGER.info("Error parsing line in playlist: " + path.toString() + "\n\tLine: " + line);
					}
				}
			} catch (Exception e) {
				LOGGER.log(Level.INFO, "Error reading playlist file: " + path, e);
				return null;
			}
			return playlist;
		}
		return null;
	}

	public String getName() {
		return name;
	}

	public void setName(String newName) {
		this.name = newName;
	}

	public PlaylistRepeatMode getRepeatMode() {
		return repeatMode;
	}

	public void setRepeatMode(PlaylistRepeatMode repeatMode) {
		if (this.repeatMode != repeatMode) {
			this.repeatMode = repeatMode;
			hasUnsavedData = true;
		}
	}

	public PlaylistShuffleMode getShuffleMode() {
		return shuffleMode;
	}

	public void setShuffleMode(PlaylistShuffleMode shuffleMode) {
		if (this.shuffleMode != shuffleMode) {
			this.shuffleMode = shuffleMode;
			hasUnsavedData = true;
		}
	}

	public int getLength() {
		int retMe = 0;
		for (Track track : tracks) {
			if (track != null) {
				retMe += track.getLengthS();
			}
		}
		return retMe;
	}

	public String getLengthDisplay() {
		return Utils.getLengthDisplay(getLength());
	}

	public int getSongCount() {
		return tracks.size();
	}

	public ObservableList<Track> getTracks() {
		return tracks;
	}

	public void setData(Playlist playlist) {
		tracks.clear();
		tracks.addAll(playlist.tracks);
		name = playlist.name;
		shuffleMode = playlist.shuffleMode;
		repeatMode = playlist.repeatMode;
		hasUnsavedData = true;
	}

	public void setTracks(List<Track> newTracks) {
		if (tracks == null) {
			tracks.clear();
		} else {
			tracks.clear();
			tracks.addAll(newTracks);
		}
	}

	public void addTrack(Track track) {
		tracks.add(track);
	}

	public void addTracks(ArrayList<Track> addMe) {
		tracks.addAll(addMe);
	}

	public void saveAs(File file) throws IOException {
		if (file == null) {
			throw new IOException("Null file specified.");
		}
		try (FileOutputStream fileOut = new FileOutputStream(file)) {
			PrintWriter playlistOut = new PrintWriter(new BufferedWriter(new OutputStreamWriter(fileOut, "UTF8")));
			playlistOut.println("#EXTM3U");
			playlistOut.printf("#Name: %s%s", getName(), System.lineSeparator());
			playlistOut.printf("#RepeatMode: %s%s", getRepeatMode(), System.lineSeparator());
			playlistOut.printf("#ShuffleMode: %s%s", getShuffleMode(), System.lineSeparator());
			playlistOut.printf("%s", System.lineSeparator());
			for (Track track : getTracks()) {
				playlistOut.printf("#EXTINF:%d,%s - %s%s", track.getLengthS(), track.getArtist(), track.getTitle(),
						System.lineSeparator());
				playlistOut.println(track.getPath().toString());
				playlistOut.println();
			}
			playlistOut.flush();
		}
	}

	public String getBaseFilename() {
		String fileSafeName = getName().replaceAll("\\W+", "");
		if (fileSafeName.length() > 12) {
			fileSafeName = fileSafeName.substring(0, 12);
		}
		String baseFileName = fileSafeName + getName().hashCode();
		return baseFileName;
	}

	private void writeObject(ObjectOutputStream out) throws IOException {
		out.defaultWriteObject();
		out.writeObject(tracks.toArray(new Track[tracks.size()]));
	}

	private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
		in.defaultReadObject();
		tracks = FXCollections.observableArrayList((Track[])in.readObject());
	}
}
 public class Track implements Serializable, AlbumInfoSource {
	private static transient final Logger LOGGER = Logger.getLogger( Track.class.getName() );
	
	private static final long serialVersionUID = 1L;
	public static final int NO_TRACK_NUMBER = -885533;

	public enum Format {
		FLAC ( "flac" ),
		MP3 ( "mp3" ),
		OGG ( "ogg" ),
		ALAC ( "alac" ),
		WAV ( "wav" ),
		M4A ( "m4a" ),
		M4B ( "m4b" ),
		M4R ( "m4r" ),
		AAC ( "AAC" ),
		AIFF ( "aiff" ),
		AC3 ( "ac3" ),
		AMR ( "amr" ),
		AU ( "au" ),
		MKA ( "mka" ),
		RA ( "ra" ),
		VOC ( "voc" ),
		WMA ( "wma" );
		
		final String extension;
		Format ( String extension ) {
			this.extension = extension;
		}
		
		public String getExtension () {
			return extension;
		}
		
		public static Format getFormat ( String extension ) {
			extension = extension.replaceAll( "^\\.", "" );
			for ( Format format : Format.values() ) {
				if ( format.getExtension().equalsIgnoreCase( extension ) ) {
					return format;
				}
			}
			return null;
		}
	}
	
	public enum ArtistTagImagePriority { 
		GENERAL ( 0 ), 
		ALBUM ( 1 ), 
		TRACK ( 2 );
		
		private int value;
		
		ArtistTagImagePriority ( int value ) {
			this.value = value;
		}
		
		public int getValue() {
			return value;
		}
	};

	private int length = 0;
	private File trackFile;
	private boolean isLossless = false;
	private long bitRate = -1;
	private int sampleRate = -1;
	private boolean isVBR = false;
	private String encodingType = "";
	private String format = "";
	private Album album = null;
	private transient StringProperty artist;
	private transient StringProperty albumArtist;
	private transient StringProperty title;
	private transient StringProperty albumTitle;
	private transient StringProperty date;
	private transient StringProperty originalDate;
	private transient IntegerProperty trackNumber;
	private transient StringProperty discSubtitle;
	private transient IntegerProperty discNumber;
	private transient IntegerProperty discCount;
	private transient StringProperty releaseType;
	private Vector <TagError> tagErrors;
	private transient LovedState lovedState;
	private boolean logTagErrors;
	
	private static final DirectoryStream.Filter<Path> imageFileFilter = new DirectoryStream.Filter<Path>() {
		@Override
		public boolean accept ( Path entry ) throws IOException {
			return Utils.isImageFile ( entry );			
		}
	};
	
	public Track ( Path trackPath ) {
		this (trackPath, false);
	}
	
	public Track ( Path trackPath, boolean logTagErrors ) {
		initializeTransientFields();
		this.trackFile = trackPath.toFile();
		if ( trackFile == null ) {
			LOGGER.log(Level.WARNING, "Null path track", new Exception());
		}
		this.logTagErrors = logTagErrors;
		refreshTagData();
	}
	
	public Track ( Track track ) {
		initializeTransientFields();
		setData ( track );
	}
	
	public void setData ( Track track ) {
		this.length = track.length;
		this.trackFile = track.trackFile;
		this.album = track.album;
		this.artist.set(track.artist.get());
		this.title.set(track.title.get());
		this.albumTitle.set(track.albumTitle.get());
		this.date.set(track.date.get());
		this.originalDate.set(track.originalDate.get());
		this.trackNumber.set(track.trackNumber.get());
		this.discSubtitle.set(track.discSubtitle.get());
		this.discNumber.set(track.discNumber.get());
		this.discCount.set(track.discCount.get());
		this.releaseType.set(track.releaseType.get());
		this.isLossless = track.isLossless;
		this.bitRate = track.bitRate;
		this.sampleRate = track.sampleRate;
		this.isVBR = track.isVBR;
		this.encodingType = track.encodingType;
		this.format = track.format;
		this.tagErrors.clear();
		this.tagErrors.addAll(track.tagErrors);	
		this.logTagErrors = track.logTagErrors;
	}

	public void setAlbum( Album album ) {
		this.album = album;
	}
	
	public Album getAlbum() {
		return album;
	}
		
	public List <TagError> getTagErrors () {
		return tagErrors;
	}
	
	public void refreshTagData() {
		Tag tag = null;
		Logger.getLogger( "org.jaudiotagger" ).setLevel( Level.OFF ); 
		try {
			AudioFile audioFile = getAudioFile();
			AudioHeader audioHeader = audioFile.getAudioHeader();
			length = audioHeader.getTrackLength();
			tag = audioFile.getTag();
			isLossless = audioHeader.isLossless();
			bitRate = audioHeader.getBitRateAsNumber();
			sampleRate = audioHeader.getSampleRateAsNumber();
			isVBR = audioHeader.isVariableBitRate();
			encodingType = audioHeader.getEncodingType();
			format = audioHeader.getFormat();
			Hypnos.getLibrary().removeTagErrors(tagErrors);
			tagErrors.clear();
			parseArtist( tag );
			parseTitle( tag ); 
			parseAlbum( tag );
			parseDate( tag );
			parseTrackNumber( tag );
			parseDiscInfo( tag );
			parseReleaseType( tag );	
		} catch ( Exception e ) {
			tagErrors.add( new TagError ( TagErrorType.CANNOT_READ_TAG, this ) );
		}
		parseFileName();
		if(this.logTagErrors) {
			Hypnos.getLibrary().addTagErrors(tagErrors);
		}
	}
	
	private AudioFile getAudioFile() throws IOException, CannotReadException, TagException, ReadOnlyFileException, InvalidAudioFrameException {
		int i = trackFile.toString().lastIndexOf('.');
		String extension = "";
		if( i > 0 ) {
		    extension = trackFile.toString().substring(i+1).toLowerCase();
		}
		if ( extension.matches( Format.AAC.getExtension() ) || extension.matches( Format.M4R.getExtension() ) ) {
			return AudioFileIO.readAs( trackFile, "m4a" );
		} else {
			return AudioFileIO.read( trackFile );
		}
	}
	
	private void parseFileName () {
		//TODO: Look at parsing track number from file name too
		String filenameArtist = "";
		String filenameYear = "";
		String filenameAlbum = "";
		String filenameTitle = "";
		try {
			filenameArtist = trackFile.toPath().getParent().getParent().getFileName().toString();
		} catch ( Exception e ) { 
			//No need to log this
		}
		try {
			String parentName = trackFile.toPath().getParent().getFileName().toString();
			String[] parentParts = parentName.split( " - " );
			if ( parentParts.length == 1 ) {
				if ( album != null ) {
					filenameAlbum = parentParts [ 0 ];
				} else {
					filenameArtist = parentParts [ 0 ];
				}
			} else if ( parentParts.length == 2 ) { 
				if ( parentParts [ 0 ].matches( "^[0-9]{4}[a-zA-Z]{0,1}" ) ) {
					filenameYear = parentParts [ 0 ];
					filenameAlbum = parentParts [ 1 ];
				} else if ( parentParts [ 1 ].matches( "^[0-9]{4}[a-zA-Z]{0,1}" ) ) {
					filenameYear = parentParts [ 1 ];
					filenameAlbum = parentParts [ 0 ];
				} else {
					filenameAlbum = parentName;
				}
			}
			//TODO: parse track number
			filenameTitle = trackFile.toPath().getFileName().toString();
			filenameTitle = filenameTitle.replaceAll(" - ", "").replaceAll(filenameArtist, "").replaceAll(filenameAlbum, "")
					.replaceAll(filenameYear, "");
		} catch ( Exception e ) { 
			//PENDING: We get an exception with albums that have [] and maybe {} in their directory structure 
		}
		//TODO: Add some error checking, only do this if we're pretty sure it's good. 
		if ( artist.get().equals( "" ) && !filenameArtist.equals( "" ) ) { artist.set(filenameArtist); }
		if ( albumArtist.get().equals( "" ) && !filenameArtist.equals( "" ) ) { albumArtist.set(filenameArtist); }
		if ( albumTitle.get().equals( "" ) && !filenameAlbum.equals( "" ) ) { albumTitle.set(filenameAlbum); }
		if ( date.get().equals( "" ) && !filenameYear.equals( "" ) ) { date.set(filenameYear); }
		if ( title.get().equals( "" ) && !filenameTitle.equals( "" )  ) { title.set(filenameTitle); }
	}
	
	private void parseArtist( Tag tag ) {
		// Do we want to do antyhing with FieldKey.ARTISTS or .ALBUM_ARTISTS or .ALBUM_ARTIST_SORT?
		if ( tag != null ) {
			albumArtist.set(tag.getFirst ( FieldKey.ALBUM_ARTIST ));
			artist.set(tag.getFirst ( FieldKey.ARTIST ));
		}
		if ( albumArtist.get().equals( "" ) ) {
			albumArtist.set(artist.get());
		}
		if ( artist.get().equals( "" ) ) {
			tagErrors.add( new TagError ( TagErrorType.MISSING_ARTIST, this ) );
		}
	}
	
	private void parseTitle ( Tag tag ) {
		if ( tag != null ) {
			title.set(tag.getFirst ( FieldKey.TITLE ));
			try { 
				if ( title.get().equals( "" ) ) {
					tagErrors.add( new TagError ( TagErrorType.MISSING_TITLE, this ) );
					title.set(tag.getFirst( FieldKey.TITLE_SORT ));
				}
			} catch ( UnsupportedOperationException e ) {
				//No problem, it doesn't exist for this file format
			}
		}	
	}
	
	private void parseAlbum ( Tag tag ) {
		if ( tag != null ) {
			albumTitle.set(tag.getFirst ( FieldKey.ALBUM ));
			try { 
				if ( albumTitle.get().equals( "" ) ) {
					tagErrors.add( new TagError ( TagErrorType.MISSING_ALBUM, this ) );
					albumTitle.set(tag.getFirst( FieldKey.ALBUM_SORT ));
				}
			} catch ( UnsupportedOperationException e ) {}
		}
	}
	
	private void parseDate ( Tag tag ) {
		if ( tag != null ) {
			try { 
				originalDate.set(tag.getFirst ( FieldKey.ORIGINAL_YEAR ));
			} catch ( UnsupportedOperationException e ) {
				//Do nothing
			}
			try { 
				date.set(tag.getFirst( FieldKey.YEAR ));
			} catch ( UnsupportedOperationException e ) {
				//Do nothing
			}
		}
		if ( date.get().equals( "" ) ) {
			tagErrors.add( new TagError ( TagErrorType.MISSING_DATE, this ) );
		}
	}
	
	private void parseTrackNumber( Tag tag ) {
		if ( tag != null ) {
			String rawText = tag.getFirst ( FieldKey.TRACK );
			String rawNoWhiteSpace = rawText.replaceAll("\\s+","");
			try { 
				if ( rawText.matches( "^[0-9]+$" ) ) { // 0, 01, 1010, 2134141, etc.
					trackNumber.set(Integer.parseInt( rawText ));
				} else if ( rawNoWhiteSpace.matches( "^[0-9]+$" ) ) { 
					trackNumber.set(Integer.parseInt( rawNoWhiteSpace ));
					tagErrors.add( new TagError ( TagErrorType.TRACK_NUMBER_EXCESS_WHITESPACE, this ) );
				} else if ( rawText.matches("^[0-9]+/.*") ) {
					trackNumber.set(Integer.parseInt( rawText.split("/")[0] ));
					tagErrors.add( new TagError ( TagErrorType.TRACK_NUMBER_HAS_DISC, this ) );
				} else if ( rawNoWhiteSpace.matches("^[0-9]+/.*") ) {
					trackNumber.set(Integer.parseInt( rawNoWhiteSpace.split("/")[0] ));
					tagErrors.add( new TagError ( TagErrorType.TRACK_NUMBER_HAS_DISC, this ) );
				} else {
					throw new NumberFormatException();
				}
			} catch ( NumberFormatException e ) {
				if ( ! rawNoWhiteSpace.equals( "" ) ) {
					tagErrors.add( new TagError ( TagErrorType.TRACK_NUMBER_INVALID_FORMAT, this, rawText ) );
				}
			}
		}
	}
	
	private void parseDiscInfo ( Tag tag ) {
		if ( tag != null ) {
			try {
				discSubtitle.set(tag.getFirst ( FieldKey.DISC_SUBTITLE ));
			} catch ( UnsupportedOperationException e ) {
				//No problem, it doesn't exist for this file format
			}
			try {
				discCount.set(Integer.valueOf( tag.getFirst ( FieldKey.DISC_TOTAL ) ));
			} catch ( NumberFormatException e ) {
				if ( ! tag.getFirst ( FieldKey.DISC_TOTAL ).equals( "" ) ) {
					tagErrors.add( new TagError ( TagErrorType.DISC_COUNT_INVALID_FORMAT, this , tag.getFirst ( FieldKey.DISC_TOTAL ) ) );
				}
			} catch ( UnsupportedOperationException e ) {
				//No problem, it doesn't exist for this file format
			}
			String rawText = "";
			String rawNoWhiteSpace = "";
			try {
				rawText = tag.getFirst ( FieldKey.DISC_NO );
				rawNoWhiteSpace = rawText.replaceAll("\\s+","");
				if ( rawText.matches( "^[0-9]+$" ) ) { // 0, 01, 1010, 2134141, etc.
					discNumber.set(Integer.parseInt( rawText ));
				} else if ( rawNoWhiteSpace.matches( "^[0-9]+$" ) ) { 
					discNumber.set(Integer.parseInt( rawNoWhiteSpace ));
					tagErrors.add( new TagError ( TagErrorType.DISC_NUMBER_EXCESS_WHITESPACE, this ) );
				} else if ( rawText.matches("^[0-9]+/.*") ) {//if matches 23/<whatever>
					discNumber.set(Integer.parseInt( rawText.split("/")[0] ));
					if ( discCount == null ) {
						discCount.set(Integer.parseInt( rawText.split("/")[1] ));
					}
					tagErrors.add( new TagError ( TagErrorType.DISC_NUMBER_HAS_TRACK, this ) );
				} else if ( rawNoWhiteSpace.matches("^[0-9]+/.*") ) {
					//if matches 23/<whatever>
					discNumber.set(Integer.parseInt( rawNoWhiteSpace.split("/")[0] ));
					if ( discCount == null ) {
						discCount.set(Integer.parseInt( rawNoWhiteSpace.split("/")[1] ));
					}
					tagErrors.add( new TagError ( TagErrorType.DISC_NUMBER_HAS_TRACK, this ) );
				} else {
					throw new NumberFormatException();
				}
			} catch ( NumberFormatException e ) {
				if ( ! rawNoWhiteSpace.equals( "" ) ) {
					tagErrors.add( new TagError ( TagErrorType.DISC_NUMBER_INVALID_FORMAT, this, rawText ) );
				}
			} catch ( UnsupportedOperationException e ) {
				//No problem, it doesn't exist for this file format
			}
		}
	}
	
	private void parseReleaseType ( Tag tag ) {
		if ( tag != null ) {
			try {
				releaseType.set(tag.getFirst ( FieldKey.MUSICBRAINZ_RELEASE_TYPE ));
			} catch ( UnsupportedOperationException e ) {
				//No problem, it doesn't exist for this file format
			}
		}
	}
		
	public String getArtist () {
		return artist.get();
	}
	
	public String getAlbumArtist() {
		return albumArtist.get();
	}
	
	public String getYear () {
		if ( !originalDate.get().isEmpty() ) {
			return originalDate.get();
		} else {
			return date.get();
		}
	}
	
	public String getAlbumTitle () {
		return albumTitle.get();
	}
	
	public String getFullAlbumTitle () {
		String retMe = albumTitle.get();
		if ( discSubtitle != null && !discSubtitle.get().equals( "" ) ) {
			retMe += " (" + discSubtitle + ")";
		} else if ( discCount != null && discCount.get() > 1 ) {
			if ( discNumber != null ) retMe += " (Disc " + discNumber + ")";
		} else if ( discNumber != null && discNumber.get() > 1 ) { 
			retMe += " (Disc " + discNumber + ")";
		}
		if ( releaseType.get() != null && !releaseType.get().equals("") && !releaseType.get().matches( "(?i:album)" ) ) {
			retMe += " [" + Utils.toReleaseTitleCase( releaseType.get() ) + "]";
		}
		return retMe;
	}		
	
	public Integer getDiscNumber() {
		return discNumber.get();
	}
	
	public Integer getDiscCount() {
		return discCount.get();
	}
	
	public String getReleaseType () {
		if ( releaseType.get() != null && !releaseType.get().matches( "(?i:album)" ) ) {
			return Utils.toReleaseTitleCase( releaseType.get() );
		} else {
			return null;
		}
	}
	
	public String getDiscSubtitle () {
		return discSubtitle.get();
	}
	
	public StringProperty getTitleProperty() {
		return title;
	}
	
	public StringProperty getArtistProperty() {
		return artist;
	}
	
	public StringProperty getAlbumArtistProperty() {
		return albumArtist;
	}
	
	public StringProperty getAlbumTitleProperty() {
		return albumTitle;
	}
	
	public IntegerProperty getTrackNumberProperty() {
		return trackNumber;
	}
	
	public String getTitle () {
		return title.get();
	}
	
	public Integer getTrackNumber () {
		return trackNumber.get();
	}	
	
	public int getLengthS () {
		return length;
	}
	
	public Path getPath() {
		return trackFile.toPath();
	}
	
	public String getFilename() {
		return trackFile.getName();
	}
	
	public String getLengthDisplay () {
		return Utils.getLengthDisplay( getLengthS () );
	}
	
	public String getShortEncodingString() {
		if ( encodingType.toLowerCase().matches( "mp3" ) ) {
			if ( isVBR ) {
				return "MP3 VBR";
			} else {
				return "MP3 " + bitRate;
			}
		} else if ( encodingType.toLowerCase().matches( "flac" ) ) {
			return encodingType;
		} else if ( encodingType.toLowerCase().matches( "aac" ) ) {			
			if ( isVBR ) {
				return "AAC VBR";
			} else {
				return "AAC " + bitRate;
			}
		} else {
			return encodingType;
		}
	}
	
	public boolean equals( Object o ) {
		if ( !( o instanceof Track ) ) return false;
		Track compareTo = (Track) o;
		return ( compareTo.getPath().toAbsolutePath().equals( getPath().toAbsolutePath() ) );
	}
	
	public int hashCode() {
		return getPath().hashCode();
	}
	
	// TODO: It's not clear to me any more that these methods belong in this class. 
	public static void saveArtistImageToTag ( File file, Path imagePath, ArtistTagImagePriority priority, boolean overwriteAll, AudioSystem audioSystem ) {
		try {
			byte[] imageBuffer = Files.readAllBytes( imagePath );
			saveImageToID3 ( file, imageBuffer, 8, priority, overwriteAll, audioSystem );
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to read image data from file" + imagePath, e );
		}
	}
	
	public static void saveArtistImageToTag ( File file, byte[] buffer, ArtistTagImagePriority priority, boolean overwriteAll, AudioSystem audioSystem ) {
		saveImageToID3 ( file, buffer, 8, priority, overwriteAll, audioSystem );
	}
	

	public static void saveAlbumImageToTag ( File file, byte[] buffer, AudioSystem audioSystem ) {
		saveImageToID3 ( file, buffer, 3, null, true, audioSystem );
	}

	private static void deleteImageFromID3 ( File file, int type, AudioSystem audioSystem ) { 
		try {
			AudioFile audioFile = AudioFileIO.read( file );
			Tag tag = audioFile.getTag();
			if ( tag instanceof ID3v1Tag ) {
				tag = new ID3v23Tag ( (ID3v1Tag)tag );
			}
			tag.deleteField( FieldKey.CUSTOM4 );
			List <Artwork> artworkList = tag.getArtworkList();
			tag.deleteArtworkField();
			for ( Artwork artwork : artworkList ) {
				if ( artwork.getPictureType() != type ) {
					tag.addField( artwork );
				}
			}
			boolean pausedPlayer = false;
			long currentPositionMS = 0;
			Track currentTrack = null;
			if ( audioSystem.getCurrentTrack() != null && audioSystem.getCurrentTrack().getPath().equals( file.toPath() ) && audioSystem.isPlaying() ) {
				currentPositionMS = audioSystem.getPositionMS();
				currentTrack = audioSystem.getCurrentTrack();
				audioSystem.stop( StopReason.WRITING_TO_TAG );
				pausedPlayer = true;
			}
			audioFile.setTag( tag );
			AudioFileIO.write( audioFile );
			if ( pausedPlayer ) {
				audioSystem.playTrack( currentTrack, true );
				audioSystem.seekMS( currentPositionMS );
				audioSystem.unpause();
			}
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to delete image from tag: " + file, e );
		}
	}
	
	private static void saveImageToID3 ( File file, byte[] buffer, int type, ArtistTagImagePriority priority, boolean overwriteAll, AudioSystem audioSystem ) {
		try {
			AudioFile audioFile = AudioFileIO.read( file );
			Tag tag = audioFile.getTag();
			if ( tag instanceof ID3v1Tag ) {
				tag = new ID3v23Tag ( (ID3v1Tag)tag );
			}
			if ( priority != null && !overwriteAll ) {
				Integer currentPriority = null;
				try {
					currentPriority = ArtistTagImagePriority.valueOf( tag.getFirst( FieldKey.CUSTOM4 ) ).getValue();
				} catch ( Exception e ) {
					currentPriority = null;
				}
				if ( currentPriority != null && currentPriority > priority.getValue() ) {
					LOGGER.log( Level.INFO, file.getName() + ": Not overwriting tag. Selected priority (" 
						+ priority.getValue() + ") " + "is less than current priority (" + currentPriority + ").", new Exception() );
					return;
				}
			}
			List <Artwork> artworkList = tag.getArtworkList();
			tag.deleteArtworkField();
			for ( Artwork artwork : artworkList ) {
				if ( artwork.getPictureType() != type ) {
					tag.addField( artwork );
				}
			}
			Artwork artwork = new StandardArtwork();
			artwork.setBinaryData( buffer );
			artwork.setPictureType( type ); 
			tag.addField( artwork );
			if ( priority != null ) {
				tag.deleteField( FieldKey.CUSTOM4 );
				tag.addField( FieldKey.CUSTOM4, priority.toString() );
			}
			boolean pausedPlayer = false;
			long currentPositionMS = 0;
			Track currentTrack = null;
			if ( audioSystem.getCurrentTrack() != null && audioSystem.getCurrentTrack().getPath().equals( file.toPath() ) && audioSystem.isPlaying() ) {
				currentPositionMS = audioSystem.getPositionMS();
				currentTrack = audioSystem.getCurrentTrack();
				audioSystem.stop( StopReason.WRITING_TO_TAG );
				pausedPlayer = true;
			}
			audioFile.setTag( tag );
			AudioFileIO.write( audioFile );
			if ( pausedPlayer ) {
				audioSystem.playTrack( currentTrack, true );
				audioSystem.seekMS( currentPositionMS );
				audioSystem.unpause();
			}
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to write image to tag: " + file, e );
		}
	}
	
	public void setAndSaveAlbumImage ( Path imagePath, AudioSystem audioSystem ) {
		try {
			byte[] imageBuffer = Files.readAllBytes( imagePath );
			setAndSaveAlbumImage ( imageBuffer, audioSystem );
		} catch ( IOException e ) {
			LOGGER.log( Level.WARNING, "Unable to read image data from image file (" + imagePath + ") can't set album image for track: " + getPath(), e );
		}
	}
	
	public void setAndSaveAlbumImage ( byte[] buffer, AudioSystem audioSystem ) {
		if ( buffer.length < 8 ) {
			return; //png signature is length 8, so might as well use that as an absolute minimum
		}
		saveAlbumImageToTag ( getPath().toFile(), buffer, audioSystem );
		if ( album != null ) {
			String extension = null;
			if ( buffer[0] == (byte)0xFF && buffer[1] == (byte)0xD8 ) {
				extension = ".jpg";
			} else if ( buffer[0] == (byte)0x89 && buffer[1] == (byte)0x50 && buffer[2] == (byte)0x4e && buffer[3] == (byte)0x47 
			&& buffer[4] == (byte)0x0d && buffer[5] == (byte)0x0A && buffer[6] == (byte)0x1A && buffer[7] == (byte)0x0A ) {
				extension = ".png";
			}		
			if ( extension == null ) {
				LOGGER.log( Level.INFO, "Invalid image file type, not saving.", new Exception() );
			}
			Path copyTo = album.getPath().resolve( "front" + extension );
			try {
				Files.deleteIfExists( album.getPath().resolve( "front.png" ) );
			} catch ( Exception e ) {
				LOGGER.log( Level.WARNING, "Unable to delete previous album cover: " + album.getPath().resolve( "front.png" ), e );
			}
			try ( FileOutputStream fos = new FileOutputStream ( copyTo.toFile() ) ) {
				fos.write( buffer );
				fos.close();
			} catch ( IOException e ) {
				LOGGER.log( Level.WARNING, "Unable to write album image to disk: " + copyTo, e );
			}
			Thread updaterThread = new Thread ( () -> {
				List <Path> tracks = Utils.getAllTracksInDirectory ( album.getPath() );
				for ( Path track : tracks ) {
					if ( !track.toAbsolutePath().equals( getPath().toAbsolutePath() ) ) {
						saveAlbumImageToTag ( track.toFile(), buffer, audioSystem );
					}
				}
			});
			updaterThread.setName ( "Album Image Tag Writer" );
			updaterThread.setDaemon( true );
			updaterThread.start();
		}
	}
	
	private Path getPreferredAlbumCoverPath () {
		if ( this.getPath().getParent() != null ) {
			ArrayList <Path> preferredFiles = new ArrayList <Path> ();
			preferredFiles.add( Paths.get ( this.getPath().getParent().toString(), "front.png" ) );
			preferredFiles.add( Paths.get ( this.getPath().getParent().toString(), "front.jpg" ) );
			preferredFiles.add( Paths.get ( this.getPath().getParent().toString(), "cover.png" ) );
			preferredFiles.add( Paths.get ( this.getPath().getParent().toString(), "cover.jpg" ) );
			preferredFiles.add( Paths.get ( this.getPath().getParent().toString(), "album.png" ) );
			preferredFiles.add( Paths.get ( this.getPath().getParent().toString(), "album.jpg" ) );
			for ( Path test : preferredFiles ) {
				if ( Files.exists( test ) && Files.isRegularFile( test ) ) {
					return test;
				}
			}
		}
		return null;
	}
	
	private Path getSecondaryAlbumCoverPath () {
		if ( album != null ) {
			if ( this.getPath().getParent() != null ) {
				ArrayList<Path> otherFiles = new ArrayList<Path>();
				try {
					DirectoryStream <Path> albumDirectoryStream = Files.newDirectoryStream ( this.getPath().getParent(), imageFileFilter );
					for ( Path imagePath : albumDirectoryStream ) { 
						if ( !imagePath.toString().toLowerCase().matches( ".*artist\\.\\w{3,6}$" ) ) {
							otherFiles.add( imagePath ); 
						}
					}
				} catch ( Exception e ) {
					LOGGER.log( Level.INFO, "Unable to get directory listing: " + this.getPath().getParent(), e ); 
				}
				for ( Path test : otherFiles ) {
					if ( Files.exists( test ) && Files.isRegularFile( test ) ) {
						return test;
					}
				}
			}
		}
		return null;
	}

	public Image getAlbumCoverImage ( ) {
		//Get the tag cover image
		//then look to folder for key files
		//then look at tag for any other suitable images
		//then take any image file from the album folder. 
		if ( !Files.exists( getPath() ) ) {
			LOGGER.log( Level.INFO, "Track file does not exist: " + getPath(), new Exception() );
			return null;
		}
		List<Artwork> artworkList = null;
		try {
			artworkList = getAudioFile().getTag().getArtworkList(); //This line can throw a NPE
		} catch ( Exception e ) {
			//Do nothing, there is no artwork in this file, no big deal. 
		}
		if ( artworkList != null ) {
			try {
				artworkList = getAudioFile().getTag().getArtworkList();
				if ( artworkList != null ) {
					for ( Artwork artwork : artworkList ) {
						if ( artwork.getPictureType() == 3 ) {	//see ID3 specification for why 3
							Image coverImage = SwingFXUtils.toFXImage((BufferedImage) artwork.getImage(), null);
							if ( coverImage != null ) return coverImage;
						} 
					}
				}			
			} catch ( ClosedByInterruptException e ) {
				return null;
			} catch ( Exception e ) {
				LOGGER.log( Level.INFO, "Unable to load album image from tag for file: " + getFilename(), e );
			}
		}
		Path bestPath = getPreferredAlbumCoverPath ();
		if ( bestPath != null ) {
			return new Image( bestPath.toUri().toString() );
		}
		artworkList = null;
		try {
			artworkList = getAudioFile().getTag().getArtworkList(); //This line can throw a NPE
		} catch ( Exception e ) {
			//Do nothing, there is no artwork in this file, no big deal. 
		}
		try {
			if ( artworkList != null ) {
				for ( Artwork artwork : artworkList ) {
					if ( artwork.getPictureType() == 0 ) {
						Image otherImage = SwingFXUtils.toFXImage((BufferedImage) artwork.getImage(), null);
						if ( otherImage != null ) return otherImage;
					} else if ( artwork.getPictureType() == 6 ) {
						Image mediaImage = SwingFXUtils.toFXImage((BufferedImage) artwork.getImage(), null);
						if ( mediaImage != null ) return mediaImage;
					}
				}
			}			
		} catch ( ClosedByInterruptException e ) {
			return null;
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Unable to load album image from tag for file: " + getFilename(), e );
		}
		Path otherPaths = getSecondaryAlbumCoverPath ();
		if ( otherPaths != null ) {
			return new Image( otherPaths.toUri().toString() );
		}
		return null;
	}

	private ArtistTagImagePriority tagImagePriority() {
		try {
			TagField tagImagePriority = getAudioFile().getTag().getFirstField( FieldKey.CUSTOM4 );
			return ArtistTagImagePriority.valueOf( tagImagePriority.toString() );
		} catch ( Exception e ) {
			return null;
		}
	}
	
	private Image getTagArtistImage () {
		try {
			List<Artwork> artworkList = getAudioFile().getTag().getArtworkList(); 
			if ( artworkList != null ) {
				for ( Artwork artwork : artworkList ) {
					if ( artwork.getPictureType() == 8 ) {
						Image artistImage = SwingFXUtils.toFXImage((BufferedImage) artwork.getImage(), null);
						if ( artistImage != null ) return artistImage;
					}
				}
			}			
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Unable to load artist image from tag for file: " + getFilename(), e );
		}
		return null;
	}
	
	public Image getArtistImage ( ) {
		if ( tagImagePriority() == ArtistTagImagePriority.TRACK ) {
			Image tagArtistImage = getTagArtistImage();
			if ( tagArtistImage != null ) return tagArtistImage;
		}
		if ( this.getPath().getParent() != null ) {
			Path targetPath = this.getPath().toAbsolutePath();
			ArrayList <Path> possibleFiles = new ArrayList <Path> ();
			possibleFiles.add( Paths.get ( targetPath.getParent().toString(), "artist.png" ) );
			possibleFiles.add( Paths.get ( targetPath.getParent().toString(), "artist.jpg" ) );
			if ( this.getPath().getParent().getParent() != null ) {
				possibleFiles.add( Paths.get ( targetPath.getParent().getParent().toString(), "artist.png" ) );
				possibleFiles.add( Paths.get ( targetPath.getParent().getParent().toString(), "artist.jpg" ) );
			}
			for ( Path test : possibleFiles ) {
				if ( Files.exists( test ) && Files.isRegularFile( test ) ) {
					return new Image( test.toUri().toString() );
				}
			}
		}
		if ( tagImagePriority() == ArtistTagImagePriority.ALBUM ) {
			Image tagArtistImage = getTagArtistImage();
			if ( tagArtistImage != null ) return tagArtistImage;
		}
		List<Artwork> artworkList = null;
		try {
			artworkList = getAudioFile().getTag().getArtworkList(); //This line can throw a NPE
		} catch ( Exception e ) {
			//Do nothing, there is no artwork in this file, no big deal. 
		}
		try {
			if ( artworkList != null ) {
				for ( Artwork artwork : artworkList ) {
					if ( artwork.getPictureType() == 7 ) {
						Image leadArtistImage = SwingFXUtils.toFXImage( (BufferedImage) artwork.getImage(), null );
						if ( leadArtistImage != null ) {
							return leadArtistImage;
						}
					} else if ( artwork.getPictureType() == 8 ) {
						Image artistImage = SwingFXUtils.toFXImage((BufferedImage) artwork.getImage(), null);
						if ( artistImage != null ) {
							return artistImage;
						}
					} else if ( artwork.getPictureType() == 12 ) {
						Image writerImage = SwingFXUtils.toFXImage( (BufferedImage) artwork.getImage(), null );
						if ( writerImage != null ) {
							return writerImage;
						}
					} else if ( artwork.getPictureType() == 13 ) {
						Image logoImage = SwingFXUtils.toFXImage( (BufferedImage) artwork.getImage(), null );
						if ( logoImage != null ) {
							return logoImage;
						}
					}
				}
			}			
		} catch ( ClosedByInterruptException e ) {
			return null;
		} catch ( Exception e ) {
			LOGGER.log( Level.INFO, "Error when trying to load tag images for file" + getPath(), e );
		}
		return null;
	}
	
	public void updateTagsAndSave ( List<MultiFileTextTagPair> textTagPairs, List<MultiFileImageTagPair> imageTagPairs, AudioSystem audioSystem ) {
		try {
			AudioFile audioFile = AudioFileIO.read( getPath().toFile() );
			Tag tag = audioFile.getTag();
			if ( tag == null ) {
				tag = audioFile.createDefaultTag();
			} else if ( tag instanceof ID3v1Tag ) {
				tag = new ID3v23Tag ( (ID3v1Tag)tag );
			}
			for ( MultiFileTextTagPair tagPair : textTagPairs ) { 
				if ( !tagPair.isMultiValue() ) {
					if ( tagPair.getValue().equals( "" ) ) {
						tag.deleteField( tagPair.getKey() );
					} else {
						tag.setField( tagPair.getKey(), tagPair.getValue() );
					}
				}
			}
			audioFile.setTag( tag );
			AudioFileIO.write( audioFile );
			for ( MultiFileImageTagPair tagPair : imageTagPairs ) {
				if ( !tagPair.isMultiValue() && tagPair.imageDataChanged() ) {
					if ( tagPair.getImageData() == null ) {
						deleteImageFromID3 ( getPath().toFile(), ImageFieldKey.getIndexFromKey( tagPair.getKey() ), audioSystem );
					} else {
						saveImageToID3 ( getPath().toFile(), tagPair.getImageData(), ImageFieldKey.getIndexFromKey( tagPair.getKey() ), 
							ArtistTagImagePriority.TRACK, true, audioSystem );
					}
				}
			}
			refreshTagData();
			if(getAlbum() != null) {
				getAlbum().updateData();
			}
		} catch ( Exception e ) {
			LOGGER.log( Level.WARNING, "Unable to save updated tag.", e );
		}
	}
	
	private void writeObject ( ObjectOutputStream out ) throws IOException {
		out.defaultWriteObject();
		out.writeObject(artist.get());
		out.writeObject(albumArtist.get());
		out.writeObject(title.get());
		out.writeObject(albumTitle.get());
		out.writeObject(date.get());
		out.writeObject(originalDate.get());
		out.writeInt(trackNumber.get());
		out.writeObject(discSubtitle.get());
		out.writeInt(discNumber.get());
		out.writeInt(discCount.get());
		out.writeObject(releaseType.get());
	}
	
	private void readObject ( ObjectInputStream in ) throws IOException, ClassNotFoundException {
		initializeTransientFields();
		in.defaultReadObject();
		artist.set((String)in.readObject());
		albumArtist.set((String)in.readObject());
		title.set((String)in.readObject());
		albumTitle.set((String)in.readObject());
		date.set((String)in.readObject());
		originalDate.set((String)in.readObject());
		trackNumber.set(in.readInt());
		discSubtitle.set((String)in.readObject());
		discNumber.set(in.readInt());
		discCount.set(in.readInt());
		releaseType.set((String)in.readObject());
	}

	public LovedState getLovedState () {
		return lovedState;
	}

	public void setLovedState ( LovedState state ) {
		this.lovedState = state;
	}
	
	private void initializeTransientFields() {
		artist = new SimpleStringProperty("");
		albumArtist = new SimpleStringProperty("");
		title = new SimpleStringProperty("");
		albumTitle = new SimpleStringProperty("");
		date = new SimpleStringProperty("");
		originalDate = new SimpleStringProperty("");
		trackNumber = new SimpleIntegerProperty(NO_TRACK_NUMBER);
		discSubtitle = new SimpleStringProperty("");
		discNumber = new SimpleIntegerProperty();
		discCount = new SimpleIntegerProperty();
		releaseType = new SimpleStringProperty("");
		tagErrors = new Vector <TagError> ();
		lovedState = LovedState.NOT_SET;
	}
}
 public enum LoaderSpeed {
	LOW, MED, HIGH
}
 public class TrayIcon {
	private static final Logger LOGGER = Logger.getLogger( TrayIcon.class.getName() );
	
	private NativeTrayIcon nativeTrayIcon;
	private BooleanProperty systemTraySupported = new SimpleBooleanProperty ( true );
	
	public TrayIcon ( FXUI ui, AudioSystem audioSystem ) {
		
		switch ( Hypnos.getOS() ) {
			case NIX:
				try {
					nativeTrayIcon = new LinuxTrayIcon( ui, audioSystem );
					nativeTrayIcon.hide();
				} catch ( Exception | UnsatisfiedLinkError e ) {
					LOGGER.log( Level.INFO, "Unable to initialize linux tray icon.", e );
					nativeTrayIcon = null;
				}
				break;
				
			case OSX:
				
			case WIN_10:
			case WIN_7:
			case WIN_8:
			case WIN_UNKNOWN:
			case WIN_VISTA:
				nativeTrayIcon = new WindowsTrayIcon( ui, audioSystem );
				break;

			case WIN_XP:
				//Do nothing, windows xp not supported
			case UNKNOWN:
			default:
		}
		
		systemTraySupported.set( nativeTrayIcon != null );
	}
	
	public boolean isSupported() {
		return systemTraySupported.get();
	}
	
	public BooleanProperty systemTraySupportedProperty() {
		return systemTraySupported;
	}
	
	public void show() {
		if ( nativeTrayIcon != null ) {
			nativeTrayIcon.show();
		}
	}
	
	public void hide() {
		if ( nativeTrayIcon != null ) {
			nativeTrayIcon.hide();
		}	
	}

	public void prepareToExit() {
		if ( nativeTrayIcon != null ) {
			nativeTrayIcon.prepareToExit();
		}
	}
}
import java.awt.Desktop;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.SwingUtilities;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Rectangle2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SplitPane;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.Tooltip;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.effect.ColorAdjust;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.DataFormat;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import javafx.util.Callback;
import javafx.util.Duration;
import net.joshuad.hypnos.CurrentList;
import net.joshuad.hypnos.CurrentList.Mode;
import net.joshuad.hypnos.Hypnos.ExitCode;
import net.joshuad.hypnos.CurrentListState;
import net.joshuad.hypnos.CurrentListTrack;
import net.joshuad.hypnos.Hypnos;
import net.joshuad.hypnos.HypnosURLS;
import net.joshuad.hypnos.Persister;
import net.joshuad.hypnos.Utils;
import net.joshuad.hypnos.Persister.Setting;
import net.joshuad.hypnos.audio.AudioSystem;
import net.joshuad.hypnos.audio.PlayerListener;
import net.joshuad.hypnos.audio.AudioSystem.RepeatMode;
import net.joshuad.hypnos.audio.AudioSystem.ShuffleMode;
import net.joshuad.hypnos.audio.AudioSystem.StopReason;
import net.joshuad.hypnos.hotkeys.GlobalHotkeys;
import net.joshuad.hypnos.library.Album;
import net.joshuad.hypnos.library.Artist;
import net.joshuad.hypnos.library.Library;
import net.joshuad.hypnos.library.Playlist;
import net.joshuad.hypnos.library.Track;
import net.joshuad.hypnos.library.Library.LoaderSpeed;
import net.joshuad.hypnos.trayicon.TrayIcon;
		});
		
		libraryPane.applySettingsBeforeWindowShown( settings );
		currentListPane.applySettingsBeforeWindowShown( settings );
		settingsWindow.updateSettingsBeforeWindowShown();
	}

	public Stage getMainStage () {
		return mainStage;
	}

	@Override
	public void playerPositionChanged ( int positionMS, int lengthMS ) {
		Platform.runLater( () -> {
			int timeElapsedS = positionMS / 1000;
			int timeRemainingS = ( lengthMS / 1000 ) - timeElapsedS;
			double percent = positionMS / (double)lengthMS;
			updateTransport( timeElapsedS, timeRemainingS, percent );
		});
	}


	@Override
	public void playerStopped ( Track track, StopReason reason ) {
		transport.playerStopped ( track, reason );
	}


	@Override
	public void playerStarted ( Track track ) {
		Platform.runLater( () -> {
			transport.playerStarted ( track );
			currentListPane.currentListTable.refresh();
		});
	}

	@Override
	public void playerPaused () {
		Platform.runLater( () -> {
			transport.togglePlayButton.setGraphic( transport.playImage );
			currentListPane.currentListTable.refresh(); //To get the play/pause image to update. 
		});
	}

	@Override
	public void playerUnpaused () {
		Platform.runLater( () -> {
			transport.togglePlayButton.setGraphic( transport.pauseImage );
			currentListPane.currentListTable.refresh();//To get the play/pause image to update. 
		});
	}

	@Override
	public void playerVolumeChanged ( double newVolumePercent ) {
		transport.playerVolumeChanged( newVolumePercent );
	}

	@Override
	public void playerShuffleModeChanged ( ShuffleMode newMode ) {
		Platform.runLater( () -> {
			currentListPane.updateShuffleButtonImages();
		});
	}

	@Override
	public void playerRepeatModeChanged ( RepeatMode newMode ) {
		Platform.runLater( () -> {
			currentListPane.updateRepeatButtonImages();
		});
	}
	
	public void libraryCleared() {
		artSplitPane.libraryCleared();
	}

	public void refreshHotkeyList () {
		settingsWindow.refreshHotkeyFields();
	}
	
	public void warnUserPlaylistsNotSaved ( List <Playlist> errors ) {
		if ( doPlaylistSaveWarning ) {
			Platform.runLater( () -> {
				doPlaylistSaveWarning = false;
				Alert alert = new Alert( AlertType.ERROR );
				double x = mainStage.getX() + mainStage.getWidth() / 2 - 220; //It'd be nice to use alert.getWidth() / 2, but it's NAN now. 
				double y = mainStage.getY() + mainStage.getHeight() / 2 - 50;

				setAlertWindowIcon( alert );
				applyCurrentTheme( alert );
				
				alert.setX( x );
				alert.setY( y );
				
				alert.setTitle( "Warning" );
				alert.setHeaderText( "Unable to save playlists." );
					
				String message = "Unable to save the following playlists to the default playlist directory. " +
						"You may want to manually export them before exiting, so your data is not lost.\n";
						
				for ( Playlist playlist : errors ) {
					
					if ( playlist != null ) {
						message += "\n" + playlist.getName();
					}
				}
					
				Text text = new Text( message );
				
				text.setWrappingWidth(500);
				text.getStyleClass().add( "alert-text" );
				HBox holder = new HBox();
				holder.getChildren().add( text );
				holder.setPadding( new Insets ( 10, 10, 10, 10 ) );
				alert.getDialogPane().setContent( holder );
				
				alert.showAndWait();
			});
		}
	}

	public void warnUserAlbumsMissing ( List <Album> missing ) {