Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

Improving the iTunes sleep timer

Jerry Stratton, April 16, 2006

In a previous article, I described a simple iTunes sleep timer that would play iTunes, wait a short period, start dropping the volume, and then finally put the whole system to sleep.

There are a couple of problems with that script, mainly having to do with what happens when you want to change your mind. The way that the simple iTunes sleep timer script currently dims the screen is to set the display to go to sleep after a second. It remembers the previous settings and attempts to restore them just before it puts the whole computer to sleep.

But what if you change your mind and quit the sleep timer script before that happens? First, you can’t. The script does not respond to a quit request. You can force-quit it, but that’s not nice, and it still doesn’t restore the display’s sleep settings.

Making an AppleScript respond to quit requests requires moving away from the step-by-step programming technique of putting everything in order and executing until every step is finished. AppleScript has special handlers that can intercept events such as “quit” if you let it. The most important handler for this is the idle handler.

We will also need to separate the restore functionality so that we can call it both before going to full sleep and before quitting.

This will be pretty much a complete redesign of the script. Some of the steps will be the same, but almost everything after the initial setup will be moved.

Stay open

First things first, open the script and do a “save as...”. Check the option called “Stay open”. In order to handle events, the script needs to stay open to receive them.

Save as a different name, such as “New Sleep Timer” so that you can always revert to the original.

Global variables

First, because we need to access the savedDisplay variable and some of the timings in multiple handlers, these variables need to be global variables. Globals are variables that are accessible throughout the script.

At the beginning of the “run” handler, just below “on run”, add:

[toggle code]

    • global savedDisplay, outTime, endTime, stepTime, stepVolume, originalVolume, originalSystemVolume

Now, any handler that includes a “globals” line will be able to access the global variables mentioned in the list. Variables not mentioned in the globals line will be “local” variables, and not interfere with the global variables used by other parts of the script.

We’ll also want to set up some defaults for these global variables, in case something happens to make them not be set up correctly:

  • set savedDisplay to false
  • set originalVolume to false
  • set originalSystemVolume to the output volume of (get volume settings)

On error

In the original script, if you cancel the dialog box the script quits. That’s because the script is not set to stay open, and cancel cancels the current handler. Our new script, however, is set to stay open even if nothing is happening. Canceling will cancel the current handler--meaning that our setup in the run handler will be ignored--but it will not cause the script to quit. We’ll need to explicitly tell the script to quit itself if we choose to cancel.

[toggle code]

  • --ask for sleep time in minutes
  • try
    • display dialog "How many minutes before sleeping? " default answer outMinutes giving up after 17 buttons {"Cancel", "Start Timer"} default button "Start Timer"
  • on error
    • --they cancelled
    • tell me to quit
    • return
  • end try
  • set outMinutes to the text returned of the result

If the cancel button is chosen, that generates an error. That’s why the original script quit. Our new script needs to “trap” that error and explicitly quit, so we do.

If you’re familiar with other scripting languages, that “return” might not make sense. Haven’t we already quit? But the “tell” line is sending an event to the script. The script won’t respond to any event until the current handler finishes. That’s part of what we‘re trying to fix--the original script wouldn’t respond to requests to quit. So we immediately tell this handler to “return”, ending it, and giving the script a chance to respond to the quit event it just sent itself.

We are also changing the name of the “OK” button to be the more informative “Start Timer”.

Prepositional parameters

Next, we need to move the restoration portion of the script into its own handler. Take the section following “--reset display sleep setting to normal” and move it into its own handler, called “restoreSleep”:

[toggle code]

  • --restoreSleep expects a record with power and displaysleep settings
  • on restoreSleep for savedDisplay
    • if savedDisplay is not false then
      • set powerType to the power of savedDisplay
      • set sleepSetting to the displaysleep of savedDisplay
      • set restoreResponse to do shell script pmset & "force -" & powerType & " displaysleep " & sleepSetting
      • if restoreResponse is not "" then
        • display dialog restoreResponse giving up after 30
      • end if
    • end if
  • end restoreSleep

We don’t put the global line in the restoreSleep handler, because we might want to send it other settings if we ever expand on this script. Using prepositional parameters allows us to send it any settings we want.

Quit handler

Now that savedDisplay is in a global variable, and we have a handler to restore the display’s sleep settings, we can call that handler with that variable whenever the script quits. Add a new handler called quit:

[toggle code]

  • on quit
    • global savedDisplay, originalVolume, originalSystemVolume
    • --restore display's settings
    • restoreSleep for savedDisplay
    • --restore volume level
    • --restore iTunes volume from zero
    • if originalVolume is not false then
      • tell application "iTunes"
        • set sound volume to originalVolume
      • end tell
    • end if
    • --restore system volume
    • set volume output volume originalSystemVolume
    • continue quit
  • end quit

Handlers such as quit, that do things on an event (such as quitting) almost always intercept that event. The quit handler intercepts the request to quit. Our quit handler calls the restoreSleep handler we already created, restores iTunes’s volume if necessary, and then restores the system volume.

The last thing the quit handler does is continue quit. Without this line, the quit handler would not only intercept quit requests, it would ignore them. When we intercept a handler we also need to “continue” it if we want the script to complete the requested event.

Idle handler

The heart of these changes to our script is the idle handler. The idle handler is called whenever the script is “idle”. That is, when it is waiting to do something. The moment the script goes idle, its idle handler is called. From then on, we can control how often it gets called by returning a number of seconds from the idle handler. The idle handler will be called again in that many seconds.

Basically, wherever we had a delay in the original script we need to have a return idletime in the idle handler.

[toggle code]

  • on idle
    • global savedDisplay, outTime, endTime, stepTime, stepVolume
    • if endTime - outTime is greater than (current date) then
      • --we are not yet ready to start dropping the volume
      • return outTime
    • else if endTime - downTime is greater than (current date) then
      • --we haven't yet started the final countdown, so just drop the volume slowly
      • tell application "iTunes"
        • set currentVolume to sound volume
        • set sound volume to currentVolume - stepVolume
      • end tell
      • return stepTime
    • else if endTime is greater than (current date) then
      • --we are in the final countdown. Drop volume to zero as we approach the end time
      • set countdown to endTime - (current date)
      • tell application "iTunes"
        • set sound volume to minimumVolume * countdown / downTime
      • end tell
      • return 1
    • else
      • --we're done. put computer to sleep
      • tell application "System Events"
        • sleep
      • end tell
      • tell me to quit
    • end if
  • end idle

There are four sections of this idle handler:

  1. When we are more than outTime seconds away from when we need to go to sleep, there’s nothing to do but wait outTime seconds.
  2. When we are less than outTime seconds away but more than downTime seconds away from the end, we drop the volume by stepVolume and we then wait stepTime seconds to do it again.
  3. When we are less than downTime seconds away from the end, we need to quickly drop the volume to zero. We only go idle for a single second during this period before dropping the volume again.
  4. Finally, if none of those options apply we are at or past the end time. We put the system to sleep and then tell the script to quit. Just as our previous script did, putting the system to sleep means that the next line--the request to quit--doesn’t happen until something wakes the system up (such as us getting up in the morning and using the keyboard or mouse).

Simplify the run handler

Having replicated most of the functionality of the run handler in the restoreSleep, idle, and quit handlers, you’ll need to delete that information from the run handler. The only thing the run handler needs to do is set up initial settings:

  • --how many seconds will this last?
  • set fullDuration to outMinutes * 60
  • --how long before we start dropping the volume?
  • set outTime to fullDuration * 0.5
  • --determine amount to step volume down by
  • set stepVolume to (startVolume - minimumVolume) / stepCount
  • --how much time between each step?
  • set stepTime to outTime / stepCount
  • --when are we finished?
  • set endTime to the (current date) + fullDuration

Extending this script

The new script, because it is free to respond to events, is much more versatile. The most commonly-used events are run, idle, and quit; another one you’ll use often is “open” (see my article on importing vinyl into iTunes for an example of the open handler). But there are many more events zipping around in Mac OS X. You can even make up your own.

For example, if you have other scripts that sometimes run and sometimes don’t, and you want to delay the sleep time if they run, you could add this handler to your sleep timer script:

[toggle code]

  • on delaySleep for delayMinutes
    • global endTime, stepCount
    • set delaySeconds to delayMinutes * 60
    • set endTime to endTime + delaySeconds
    • set stepCount to stepCount + delaySeconds / stepCount
  • end delaySleep

Your AppleScript now responds to tell requests sent by other scripts. You can put lines like these in other scripts to delay the sleep timer:

[toggle code]

  • tell application "Sleep Timer New"
    • delaySleep for 20
  • end tell

A script with these lines will cause the sleep timer to put the system to sleep 20 minutes later than it otherwise would have.

The finished script

Here’s the final script. It works basically the same way as the original script did, but now we can quit it when necessary; it also restores volumes to their original level before it quits, regardless of whether it quits on its own or on request. I’ve not included the example delaySleep extension.

[toggle code]

  • --time, in minutes, before it goes to sleep
  • property outMinutes : 35
  • --time, in seconds, for volume to quickly drop to zero when the time is up
  • property downTime : 15
  • --volumes for nighttime
  • property systemVolume : 50
  • property startVolume : 60
  • property minimumVolume : 35
  • property pmset : "/usr/bin/pmset "
  • --number of volume drops after halfway point
  • property stepCount : 10
  • on run
    • global savedDisplay, outTime, endTime, stepTime, stepVolume, originalVolume, originalSystemVolume
    • set savedDisplay to false
    • set originalVolume to false
    • set originalSystemVolume to the output volume of (get volume settings)
    • --ask for sleep time in minutes
    • try
      • display dialog "How many minutes before sleeping? " default answer outMinutes giving up after 17 buttons {"Cancel", "Start Timer"} default button "Start Timer"
    • on error
      • --they cancelled
      • tell me to quit
      • return
    • end try
    • set outMinutes to the text returned of the result
    • --set display to sleep quickly
    • set savedDisplay to sleepDisplaySoon()
    • --set volumes appropriately for sleep
    • set volume output volume systemVolume
    • tell application "iTunes"
      • set originalVolume to sound volume
      • set sound volume to startVolume
      • if player state is paused or player state is stopped then
        • playpause
      • end if
    • end tell
    • --how many seconds will this last?
    • set fullDuration to outMinutes * 60
    • --how long before we start dropping the volume?
    • set outTime to fullDuration * 0.5
    • --determine amount to step volume down by
    • set stepVolume to (startVolume - minimumVolume) / stepCount
    • --how much time between each step?
    • set stepTime to outTime / stepCount
    • --when are we finished?
    • set endTime to the (current date) + fullDuration
  • end run
  • on idle
    • global savedDisplay, outTime, endTime, stepTime, stepVolume
    • if endTime - outTime is greater than (current date) then
      • --we are not yet ready to start dropping the volume
      • return outTime
    • else if endTime - downTime is greater than (current date) then
      • --we haven't yet started the final countdown, so just drop the volume slowly
      • tell application "iTunes"
        • set currentVolume to sound volume
        • set sound volume to currentVolume - stepVolume
      • end tell
      • return stepTime
    • else if endTime is greater than (current date) then
      • --we are in the final countdown. Drop volume to zero as we approach the end time
      • set countdown to endTime - (current date)
      • tell application "iTunes"
        • set sound volume to minimumVolume * countdown / downTime
      • end tell
      • return 1
    • else
      • --we're done. put computer to sleep
      • tell application "System Events"
        • sleep
      • end tell
      • tell me to quit
    • end if
  • end idle
  • on quit
    • global savedDisplay, originalVolume, originalSystemVolume
    • --restore display's settings
    • restoreSleep for savedDisplay
    • --restore volume level
    • --restore iTunes volume from zero
    • if originalVolume is not false then
      • tell application "iTunes"
        • set sound volume to originalVolume
      • end tell
    • end if
    • --restore system volume
    • set volume output volume originalSystemVolume
    • continue quit
  • end quit
  • on sleepDisplaySoon()
    • --get the current display sleep settings
    • set origSettings to do shell script pmset & "-g"
    • set settingsList to the paragraphs of origSettings
    • set powerType to ""
    • set displaySleepSetting to ""
    • --go through each line looking for the settings we want
    • repeat with settingLine in settingsList
      • --first, we get the kind of power: AC or battery
      • if powerType is "" then
        • if settingLine ends with "*" then
          • --this is the currently active profile
          • if settingLine begins with "AC Power" then
            • set powerType to "c"
          • else if settingLine begins with "Battery" then
            • set powerType to "b"
          • end if
        • end if
      • else
        • --we have a power type, look for display sleep time
        • if settingLine begins with " displaysleep" then
          • set displaySleepSetting to the second word of settingLine
          • set pmsetCMD to pmset & "force -" & powerType & " displaysleep 1"
          • set displayResponse to do shell script pmsetCMD
          • if displayResponse is not "" then
            • display dialog displayResponse giving up after 6
          • end if
          • return {power:powerType, displaysleep:displaySleepSetting}
        • end if
      • end if
    • end repeat
    • display dialog "Display not slept." giving up after 4
    • return false
  • end sleepDisplaySoon
  • --restoreSleep expects a record with power and displaysleep settings
  • on restoreSleep for savedDisplay
    • if savedDisplay is not false then
      • set powerType to the power of savedDisplay
      • set sleepSetting to the displaysleep of savedDisplay
      • set restoreResponse to do shell script pmset & "force -" & powerType & " displaysleep " & sleepSetting
      • if restoreResponse is not "" then
        • display dialog restoreResponse giving up after 30
      • end if
    • end if
  • end restoreSleep
  1. <- Invariant sections
  2. Effective Exposé ->