public enum DefaultRepeatMode {
	PLAY_ONCE, REPEAT, NO_CHANGE
} public enum DefaultShuffleMode {
	SEQUENTIAL, SHUFFLE, NO_CHANGE
} 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 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 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; }
} 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 TagError implements Serializable {
	
	private static final long serialVersionUID = 1L;

	public enum TagErrorType {
		MISSING_ARTIST ( "No artist name" ),
		MISSING_TITLE ( "No track title" ),
		MISSING_ALBUM ( "No album title" ),
		MISSING_DATE ( "No date." ),
		TRACK_NUMBER_EXCESS_WHITESPACE ( "Track # has excess whitespace" ),
		TRACK_NUMBER_HAS_DISC ( "Track # in N/N format" ),
		TRACK_NUMBER_INVALID_FORMAT ( "Track # format invalid" ),
		DISC_COUNT_INVALID_FORMAT ( "Disc count format invalid" ),
		DISC_NUMBER_EXCESS_WHITESPACE ( "Disc # has excess whitespace" ),
		DISC_NUMBER_HAS_TRACK ( "Disc # in N/N format" ),
		DISC_NUMBER_INVALID_FORMAT ( "Disc # format invalid" ),
		CANNOT_READ_TAG ( "Unable to read tag." ); 
		
		private String message;
		
		private TagErrorType ( String message ) {
			this.message = message;
		}
		
		public String getMessage () {
			return message;
		}
	}
	
	private TagErrorType type;
	private Track track;
	private String moreInfo;
	
	public TagError ( TagErrorType type, Track track ) {
		this.type = type;
		this.track = track;
	}
	
	public TagError ( TagErrorType type, Track track, String moreInfo ) {
		this ( type, track );
		this.moreInfo = moreInfo;
	}
	
	public Path getPath() {
		return track.getPath();
	}
	
	public String getFolderDisplay() {
		return getPath().getParent().toString();
	}
	
	public String getFilenameDisplay() {
		return getPath().getFileName().toString();
	}
	
	public String getMessage() {
		if ( moreInfo == null ) {
			return type.getMessage();
		} else {
			return type.getMessage() + ": " + moreInfo ;
		}
	}
	
	public Track getTrack() {
		return track;
	}
} 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 class HotkeyState implements Serializable {
	
	private static final long serialVersionUID = 1L;

	private KeyCode keyCode; 
	
	private boolean isShiftDown;
	private boolean isControlDown;
	private boolean isAltDown;
	private boolean isMetaDown;

	public HotkeyState ( KeyCode keyCode, boolean shiftDown, boolean controlDown, boolean altDown, boolean metaDown ) {
		this.keyCode = keyCode;
		this.isShiftDown = shiftDown;
		this.isControlDown = controlDown;
		this.isAltDown = altDown;
		this.isMetaDown = metaDown;
	}
	
	public HotkeyState ( KeyEvent event ) {
		keyCode = event.getCode();
		isShiftDown = event.isShiftDown();
		isControlDown = event.isControlDown();
		isAltDown = event.isAltDown();
		isMetaDown = event.isMetaDown();
	}
	
	public static String getDisplayText ( KeyEvent event ) {
		return new HotkeyState ( event ).getDisplayText();
	}
	
	public KeyCode getCode() {
		return keyCode;
	}
	
	public boolean isShiftDown() {
		return isShiftDown;
	}
	
	public boolean isControlDown() {
		return isControlDown;
	}
	
	public boolean isAltDown() {
		return isAltDown;
	}
	
	public boolean isMetaDown() {
		return isMetaDown;
	}
	
	public String getDisplayText ( ) {
		String shortcut = "";
		if ( isControlDown() ) shortcut += "Ctrl + ";
		if ( isAltDown() ) shortcut += "Alt + ";
		if ( isShiftDown() ) shortcut += "Shift + ";
		if ( isMetaDown() ) shortcut += "Meta + ";
		
		if ( keyCode != KeyCode.CONTROL && keyCode != KeyCode.ALT
		&& keyCode != KeyCode.SHIFT && keyCode != KeyCode.META 
		&& keyCode != KeyCode.ALT_GRAPH && keyCode != KeyCode.WINDOWS
		&& keyCode != KeyCode.ESCAPE ) {
			shortcut += keyCode.getName();
		}
		
		return shortcut;
	}
	
	public boolean equals ( Object compareTo ) {
		if ( !(compareTo instanceof HotkeyState) ) return false;
		
		HotkeyState compareState = (HotkeyState)compareTo;
		
		if ( !getCode().equals( compareState.getCode() ) ) return false;
		if ( isShiftDown() != compareState.isShiftDown() ) return false;
		if ( isControlDown() != compareState.isControlDown() ) return false;
		if ( isAltDown() != compareState.isAltDown() ) return false;
		if ( isMetaDown() != compareState.isMetaDown() ) return false;

		return true;
	}
} 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 enum DragSource {
	CURRENT_LIST,
	ARTIST_LIST,
	ALBUM_LIST,
	TRACK_LIST,
	PLAYLIST_LIST,
	TAG_ERROR_LIST,
	PLAYLIST_INFO,
	ALBUM_INFO,
	QUEUE,
	HISTORY,
	CURRENT_TRACK
}
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.PasswordField;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.Slider;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.Dragboard;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextAlignment;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import javafx.util.Duration;
import net.joshuad.hypnos.CurrentList.DefaultRepeatMode;
import net.joshuad.hypnos.CurrentList.DefaultShuffleMode;
import net.joshuad.hypnos.Hypnos;
import net.joshuad.hypnos.hotkeys.GlobalHotkeys;
import net.joshuad.hypnos.hotkeys.GlobalHotkeys.Hotkey;
import net.joshuad.hypnos.library.Library;
import net.joshuad.hypnos.library.Playlist;
import net.joshuad.hypnos.library.TagError;
import net.joshuad.hypnos.library.Track;
import net.joshuad.hypnos.hotkeys.HotkeyState;
import net.joshuad.hypnos.audio.AudioSystem;
import net.joshuad.hypnos.fxui.DraggedTrackContainer.DragSource;
package net.joshuad.hypnos.fxui;



public class SettingsWindow extends Stage {

	private static final Logger LOGGER = Logger.getLogger( SettingsWindow.class.getName() );
	
	private FXUI ui;
	private Library library;
	private GlobalHotkeys hotkeys;
	private AudioSystem audioSystem;
	
	private TabPane tabPane;
	private Tab globalHotkeysTab;
	
	private EnumMap <Hotkey, TextField> hotkeyFields = new EnumMap <Hotkey, TextField> ( Hotkey.class );
	
	private ChoiceBox <String> albumShuffleChoices;
	private ChoiceBox <String> albumRepeatChoices;
	private ChoiceBox <String> trackShuffleChoices;
	private ChoiceBox <String> trackRepeatChoices;
	private ChoiceBox <String> playlistShuffleChoices;
	private ChoiceBox <String> playlistRepeatChoices;
	
	private TextField userInput;
	private PasswordField passwordInput;
	
	private ToggleButton lightTheme, darkTheme;
	private ToggleGroup themeToggleGroup;
	
	private Hyperlink updateLink;
	
	private TableView <TagError> tagTable;
	
	private List <TextField> globalHotkeyFields = new ArrayList<> ();
	
	SettingsWindow( FXUI ui, Library library, GlobalHotkeys hotkeys, AudioSystem audioSystem ) {
		super();
		
		this.ui = ui;
		this.library = library;
		this.hotkeys = hotkeys;
		this.audioSystem = audioSystem;

		initModality( Modality.NONE );
		initOwner( ui.getMainStage() );		
		setWidth ( 700 );
		setHeight ( 650 );
		setTitle( "Config and Info" );
		Pane root = new Pane();
		Scene scene = new Scene( root );
		
		tabPane = new TabPane();
		
		Tab settingsTab = setupSettingsTab( root, ui );

		globalHotkeysTab = setupGlobalHotkeysTab( root );
		if ( hotkeys.isDisabled() ) {
			globalHotkeysTab.setDisable( true );
			globalHotkeysTab.setTooltip( new Tooltip ( hotkeys.getReasonDisabled() ) );
		}
		
		Tab hotkeysTab = setupHotkeysTab( root );
		Tab logTab = setupLogTab( root );
		Tab tagTab = setupTagTab( root );
		Tab lastFMTab = setupLastFMTab( root );
		Tab aboutTab = setupAboutTab( root ); 
		
		tabPane.getTabs().addAll( settingsTab, hotkeysTab, globalHotkeysTab, logTab, tagTab, lastFMTab, aboutTab );
		
		tabPane.getSelectionModel().selectedItemProperty().addListener(
		    new ChangeListener<Tab>() {
		        @Override
		        public void changed(ObservableValue<? extends Tab> observable, Tab oldValue, Tab newValue) {
		            if ( newValue == globalHotkeysTab ) {
		            	hotkeys.beginEditMode();
		            } else if ( oldValue == globalHotkeysTab ) {
		            	hotkeys.endEditMode();
		            }
		        }
		    }
		);
		
		this.showingProperty().addListener( ( obs, previousValue, isNowShowing ) -> {
			if ( isNowShowing ) {
				if ( tabPane.getSelectionModel().getSelectedItem() == globalHotkeysTab ) {
					hotkeys.beginEditMode();
				}
			} else {
				hotkeys.endEditMode();
			}
		});
		
		tabPane.prefWidthProperty().bind( root.widthProperty() );
		tabPane.prefHeightProperty().bind( root.heightProperty() );

		VBox primaryPane = new VBox();
		primaryPane.getChildren().addAll( tabPane );
		root.getChildren().add( primaryPane );
		setScene( scene );
		refreshHotkeyFields();
		
		setOnCloseRequest ( ( WindowEvent event ) -> {
			Hypnos.getPersister().saveHotkeys();