Don't post a notification when the stream gives us a meaningless change that won't show up in the notification. Fixes GrowlTunes spamming the user when iTunes is playing a Live365 stream.
2 Copyright (c) The Growl Project, 2004
6 Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
9 1. Redistributions of source code must retain the above copyright
10 notice, this list of conditions and the following disclaimer.
11 2. Redistributions in binary form must reproduce the above copyright
12 notice, this list of conditions and the following disclaimer in the
13 documentation and/or other materials provided with the distribution.
14 3. Neither the name of Growl nor the names of its contributors
15 may be used to endorse or promote products derived from this software
16 without specific prior written permission.
19 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 // GrowlTunesController.m
27 // Created by Nelson Elhage on Mon Jun 21 2004.
28 // Copyright (c) 2004 Nelson Elhage. All rights reserved.
31 #import "GrowlTunesController.h"
32 #import "GrowlTunesPlugin.h"
33 #import "NSWorkspaceAdditions.h"
35 @interface NSString (GrowlTunesMultiplicationAdditions)
37 - (NSString *)stringByMultiplyingBy:(NSUInteger)multi;
41 #define ONLINE_HELP_URL @"http://growl.info/documentation/growltunes.php"
43 @interface GrowlTunesController (PRIVATE)
44 - (NSAppleScript *) appleScriptNamed:(NSString *)name;
45 - (void) addTuneToRecentTracks:(NSString *)inTune fromPlaylist:(NSString *)inPlaylist;
46 - (NSMenu *) buildiTunesSubmenu;
47 - (NSMenu *) buildRatingSubmenu;
48 - (void) jumpToTune:(id) sender;
51 #define ITUNES_TRACK_CHANGED @"Changed Tracks"
52 #define ITUNES_PAUSED @"Paused"
53 #define ITUNES_STOPPED @"Stopped"
54 #define ITUNES_PLAYING @"Started Playing"
56 #define APP_NAME @"GrowlTunes"
57 #define ITUNES_APP_NAME @"iTunes.app"
58 #define ITUNES_BUNDLE_ID @"com.apple.itunes"
60 #define POLL_INTERVAL_KEY @"Poll interval"
61 #define NO_MENU_KEY @"GrowlTunesWithoutMenu"
62 #define RECENT_TRACK_COUNT_KEY @"Recent Tracks Count"
64 #define DEFAULT_POLL_INTERVAL 2
65 #define DEFAULT_RECENT_TRACKS_LIMIT 20U
67 //status item menu item tags.
77 @implementation GrowlTunesController
81 /* NOTE: The class currently gets instatiated from within a nib file, therefore init will get called
82 regardless. Would be cleaner if the app didnt use a nib file, but I didnt have the energy to work out
83 how to get a decent return value from NSApplication when setting things up manaully ala GHA. For now
84 I've just overridden init to return the sharedInstance */
85 return [[self class] sharedInstance];
88 - (id) initSingleton {
89 self = [super initSingleton];
93 [GrowlApplicationBridge setGrowlDelegate:self];
95 NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
96 NSDictionary *defaultDefaults = [[NSDictionary alloc] initWithObjectsAndKeys:
97 [NSNumber numberWithDouble:DEFAULT_POLL_INTERVAL], POLL_INTERVAL_KEY,
98 [NSNumber numberWithInt:20], RECENT_TRACK_COUNT_KEY,
100 [defaults registerDefaults:defaultDefaults];
101 [defaultDefaults release];
104 NSNumber *recentTrackCountNum = [defaults objectForKey:RECENT_TRACK_COUNT_KEY];
105 recentTracks = [[NSMutableArray alloc] initWithCapacity:(recentTrackCountNum ? [recentTrackCountNum unsignedIntValue] : DEFAULT_RECENT_TRACKS_LIMIT)];
107 plugins = [[self loadPlugins] retain];
115 - (void) applicationWillFinishLaunching: (NSNotification *)notification {
116 #pragma unused(notification)
117 getInfoScript = [self appleScriptNamed:@"jackItunesArtwork"];
119 NSString *itunesPath = [[NSWorkspace sharedWorkspace] fullPathForApplication:@"iTunes"];
120 if ([[[NSBundle bundleWithPath:itunesPath] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] floatValue] >= 4.7f)
121 [self setPolling:NO];
123 [self setPolling:YES];
126 pollScript = [self appleScriptNamed:@"jackItunesInfo"];
127 pollInterval = [[NSUserDefaults standardUserDefaults] floatForKey:POLL_INTERVAL_KEY];
129 if ([self iTunesIsRunning]) [self startTimer];
131 NSNotificationCenter *workspaceCenter = [[NSWorkspace sharedWorkspace] notificationCenter];
132 [workspaceCenter addObserver:self
133 selector:@selector(handleAppLaunch:)
134 name:NSWorkspaceDidLaunchApplicationNotification
137 [workspaceCenter addObserver:self
138 selector:@selector(handleAppQuit:)
139 name:NSWorkspaceDidTerminateApplicationNotification
142 [[NSDistributedNotificationCenter defaultCenter] addObserver:self
143 selector:@selector(songChanged:)
144 name:@"com.apple.iTunes.playerInfo"
147 if (![[NSUserDefaults standardUserDefaults] boolForKey:NO_MENU_KEY])
148 [self createStatusItem];
151 - (void) applicationWillTerminate:(NSNotification *)notification {
152 #pragma unused(notification)
153 [[NSDistributedNotificationCenter defaultCenter] removeObserver:self];
154 [[[NSWorkspace sharedWorkspace] notificationCenter] removeObserver:self];
156 [self tearDownStatusItem];
158 [pollScript release];
159 [getInfoScript release];
160 [recentTracks release];
166 [archivePlugin release];
170 #pragma mark Growl delegate conformance
172 - (NSDictionary *) registrationDictionaryForGrowl {
173 NSArray *allNotes = [[NSArray alloc] initWithObjects:
174 ITUNES_TRACK_CHANGED,
179 NSDictionary *readableNames = [NSDictionary dictionaryWithObjectsAndKeys:
180 NSLocalizedString(@"Changed Tracks", nil), ITUNES_TRACK_CHANGED,
181 NSLocalizedString(@"Started Playing", nil), ITUNES_PLAYING,
184 NSImage *iTunesIcon = [[NSWorkspace sharedWorkspace] iconForApplication:ITUNES_APP_NAME];
185 NSDictionary *regDict = [NSDictionary dictionaryWithObjectsAndKeys:
186 APP_NAME, GROWL_APP_NAME,
187 [iTunesIcon TIFFRepresentation], GROWL_APP_ICON,
188 allNotes, GROWL_NOTIFICATIONS_ALL,
189 allNotes, GROWL_NOTIFICATIONS_DEFAULT,
190 readableNames, GROWL_NOTIFICATIONS_HUMAN_READABLE_NAMES,
196 - (NSString *) applicationNameForGrowl {
200 - (void) setPolling:(BOOL)flag {
206 - (NSString *) starsForRating:(NSNumber *)aRating withStarCharacter:(unichar)star {
207 int rating = aRating ? [aRating intValue] : 0;
210 BLACK_STAR = 0x272F, SPACE = 0x0020, MIDDLE_DOT = 0x00B7,
212 ONE_QUARTER = 0x00BC, THREE_QUARTERS = 0x00BE,
213 ONE_THIRD = 0x2153, TWO_THIRDS = 0x2154,
214 ONE_FIFTH = 0x2155, TWO_FIFTHS = 0x2156, THREE_FIFTHS = 0x2157, FOUR_FIFTHS = 0x2158,
215 ONE_SIXTH = 0x2159, FIVE_SIXTHS = 0x215a,
216 ONE_EIGHTH = 0x215b, THREE_EIGHTHS = 0x215c, FIVE_EIGHTHS = 0x215d, SEVEN_EIGHTHS = 0x215e,
218 //rating <= 0: dot, space, dot, space, dot, space, dot, space, dot (five dots).
219 //higher ratings mean fewer characters. rating >= 100: five black stars.
223 static unichar fractionChars[] = {
225 /*1/20*/ ONE_FIFTH, TWO_FIFTHS, THREE_FIFTHS,
226 /*4/20 = 1/5*/ ONE_FIFTH,
227 /*5/20 = 1/4*/ ONE_QUARTER,
228 /*6/20*/ ONE_THIRD, FIVE_EIGHTHS,
229 /*8/20 = 2/5*/ TWO_FIFTHS, TWO_FIFTHS,
230 /*10/20 = 1/2*/ ONE_HALF, ONE_HALF,
231 /*12/20 = 3/5*/ THREE_FIFTHS,
232 /*13/20 = 0.65; 5/8 = 0.625*/ FIVE_EIGHTHS,
233 /*14/20 = 7/10*/ FIVE_EIGHTHS, //highly approximate, of course, but it's as close as I could get :)
234 /*15/20 = 3/4*/ THREE_QUARTERS,
235 /*16/20 = 4/5*/ FOUR_FIFTHS, FOUR_FIFTHS,
236 /*18/20 = 9/10*/ SEVEN_EIGHTHS, SEVEN_EIGHTHS, //another approximation
239 unichar starBuffer[numChars];
240 int wholeStarRequirement = 20;
241 unsigned starsRemaining = 5U;
243 for (; starsRemaining--; ++i) {
244 if (rating >= wholeStarRequirement) {
245 starBuffer[i] = star;
249 *if the original rating is 95, then rating = 15, and we get 3/4.
250 *if the original rating is 80, then rating = 0, and we get MIDDLE DOT.
252 starBuffer[i] = fractionChars[rating];
253 if (!starBuffer[i]) {
254 //add a space if this isn't the first 'star'.
255 if (i) starBuffer[i++] = SPACE;
256 starBuffer[i] = MIDDLE_DOT;
258 rating = 0; //ensure that remaining characters are MIDDLE DOT.
262 return [NSString stringWithCharacters:starBuffer length:i];
265 - (NSString *) starsForRating:(NSNumber *)aRating withStarString:(NSString *)star {
267 star = [[NSUserDefaults standardUserDefaults] stringForKey:@"Substitute for BLACK STAR"];
270 BLACK_STAR = 0x2605, PINWHEEL_STAR = 0x272F,
271 SPACE = 0x0020, MIDDLE_DOT = 0x00B7,
273 ONE_QUARTER = 0x00BC, THREE_QUARTERS = 0x00BE,
274 ONE_THIRD = 0x2153, TWO_THIRDS = 0x2154,
275 ONE_FIFTH = 0x2155, TWO_FIFTHS = 0x2156, THREE_FIFTHS = 0x2157, FOUR_FIFTHS = 0x2158,
276 ONE_SIXTH = 0x2159, FIVE_SIXTHS = 0x215a,
277 ONE_EIGHTH = 0x215b, THREE_EIGHTHS = 0x215c, FIVE_EIGHTHS = 0x215d, SEVEN_EIGHTHS = 0x215e,
280 unsigned starLength = [star length];
281 if( (!star) || (starLength == 0U))
282 return [self starsForRating:aRating withStarCharacter:(floor(NSAppKitVersionNumber) > NSAppKitVersionNumber10_3_5) ? PINWHEEL_STAR : BLACK_STAR];
283 else if (starLength == 1U)
284 return [self starsForRating:aRating withStarCharacter:[star characterAtIndex:0U]];
286 int rating = aRating ? [aRating intValue] : 0;
288 int ratingInv = 100 - rating;
290 int numStars = rating / 20;
291 int numDots = ratingInv / 20;
292 unsigned fractionIndex = ratingInv % 20;
294 static unichar fractionChars[] = {
296 /*1/20*/ ONE_FIFTH, TWO_FIFTHS, THREE_FIFTHS,
297 /*4/20 = 1/5*/ ONE_FIFTH,
298 /*5/20 = 1/4*/ ONE_QUARTER,
299 /*6/20*/ ONE_THIRD, FIVE_EIGHTHS,
300 /*8/20 = 2/5*/ TWO_FIFTHS, TWO_FIFTHS,
301 /*10/20 = 1/2*/ ONE_HALF, ONE_HALF,
302 /*12/20 = 3/5*/ THREE_FIFTHS,
303 /*13/20 = 0.65; 5/8 = 0.625*/ FIVE_EIGHTHS,
304 /*14/20 = 7/10*/ FIVE_EIGHTHS, //highly approximate, of course, but it's as close as I could get :)
305 /*15/20 = 3/4*/ THREE_QUARTERS,
306 /*16/20 = 4/5*/ FOUR_FIFTHS, FOUR_FIFTHS,
307 /*18/20 = 9/10*/ SEVEN_EIGHTHS, SEVEN_EIGHTHS, //another approximation
310 unichar *buf = alloca(sizeof(unichar) * ((numDots * 2) - (!rating) + (fractionIndex > 0)));
312 if (fractionIndex > 0)
313 buf[i++] = fractionChars[fractionIndex];
315 //place first dot without a leading space.
316 if ((!rating) && numDots) {
317 buf[i++] = MIDDLE_DOT;
323 buf[i++] = MIDDLE_DOT;
326 //place first star without a leading space.
327 NSString *firstStar = nil;
328 if ((starLength > 1U) && ([star characterAtIndex:0U] == SPACE)) {
329 NSRange range = { 1U, starLength - 1U };
330 firstStar = [star substringWithRange:range];
333 NSString *stars = (numStars && firstStar) ? [firstStar stringByAppendingString:[star stringByMultiplyingBy:numStars - 1]] : [star stringByMultiplyingBy:numStars];
334 NSString *dots = [[NSString alloc] initWithCharacters:buf length:i];
335 NSString *ratingString = [stars stringByAppendingString:dots];
342 - (NSString *) starsForRating:(NSNumber *)rating {
343 return [self starsForRating:rating withStarString:nil];
347 #pragma mark iTunes 4.7 notifications
349 - (void) songChanged:(NSNotification *)aNotification {
350 NSString *playerState = nil;
351 iTunesState newState = itUNKNOWN;
352 NSString *newTrackURL = nil;
353 NSDictionary *userInfo = [aNotification userInfo];
355 playerState = [[aNotification userInfo] objectForKey:@"Player State"];
356 if ([playerState isEqualToString:@"Paused"]) {
358 } else if ([playerState isEqualToString:@"Stopped"]) {
359 newState = itSTOPPED;
363 } else if ([playerState isEqualToString:@"Playing"]){
364 newState = itPLAYING;
365 /*For radios and files, the ID is the location.
366 *For iTMS purchases, it's the Store URL.
367 *For Bonjour shares, we'll hash a compilation of a bunch of info.
369 if ([userInfo objectForKey:@"Location"]) {
370 newTrackURL = [userInfo objectForKey:@"Location"];
371 } else if ([userInfo objectForKey:@"Store URL"]) {
372 newTrackURL = [userInfo objectForKey:@"Store URL"];
374 /*Get all the info we can, in such a way that the empty fields are
375 * blank rather than (null).
376 *Then we hash it and turn that into our identifier string.
377 *That way a track name of "file://foo" won't confuse our code later on.
379 NSArray *keys = [[NSArray alloc] initWithObjects:@"Name", @"Artist",
380 @"Album", @"Composer", @"Genre", @"Year", @"Track Number",
381 @"Track Count", @"Disc Number", @"Disc Count", @"Total Time",
382 @"Stream Title", nil];
383 NSArray *args = [userInfo objectsForKeys:keys notFoundMarker:@""];
385 newTrackURL = [args componentsJoinedByString:@"|"];
386 newTrackURL = [[NSNumber numberWithUnsignedLong:[newTrackURL hash]] stringValue];
391 NSString *track = nil;
392 NSString *length = nil;
393 NSString *artist = @"";
394 NSString *composer = @"";
395 NSString *album = @"";
396 BOOL compilation = NO;
397 NSString *genre = @"";
398 NSNumber *rating = nil;
399 NSString *ratingString = nil;
400 NSImage *artwork = nil;
401 NSDictionary *error = nil;
402 NSString *displayString;
403 NSString *streamTitle = @"";
405 artist = [userInfo objectForKey:@"Artist"];
406 album = [userInfo objectForKey:@"Album"];
407 composer = [userInfo objectForKey:@"Composer"];
409 if ([userInfo objectForKey:@"Track Number"]) {
410 track = [[NSString alloc] initWithFormat:@"%@. %@", [userInfo objectForKey:@"Track Number"], [userInfo objectForKey:@"Name"]];
412 //track number is nil for radio streams, ignore it
413 track = [userInfo objectForKey:@"Name"];
415 genre = [userInfo objectForKey:@"Genre"];
416 streamTitle = [userInfo objectForKey:@"Stream Title"];
420 length = [userInfo objectForKey:@"Total Time"];
421 // need to format a bit the length as it is returned in ms
422 int sec = [length intValue] / 1000;
428 length = [NSString stringWithFormat:@"%d:%02d:%02d", hr, min, sec];
430 length = [NSString stringWithFormat:@"%d:%02d", min, sec];
432 compilation = ([userInfo objectForKey:@"Compilation"] != nil);
434 if ([newTrackURL hasPrefix:@"file:/"] || [newTrackURL hasPrefix:@"itms:/"]) {
435 NSAppleEventDescriptor *theDescriptor = [getInfoScript executeAndReturnError:&error];
436 NSAppleEventDescriptor *curDescriptor;
438 rating = [userInfo objectForKey:@"Rating"];
439 ratingString = [self starsForRating:rating];
440 trackRating = [rating intValue];
442 curDescriptor = [theDescriptor descriptorAtIndex:2L];
443 playlistName = [curDescriptor stringValue];
444 curDescriptor = [theDescriptor descriptorAtIndex:1L];
445 const OSType type = [curDescriptor typeCodeValue];
448 artwork = [[[NSImage alloc] initWithData:[curDescriptor data]] autorelease];
451 //get artwork via plugins if needed (for file:/ and itms:/ id only)
452 if (!artwork && ![newTrackURL hasPrefix:@"http://"]) {
453 NSEnumerator *pluginEnum = [plugins objectEnumerator];
454 id <GrowlTunesPlugin> plugin;
455 while (!artwork && (plugin = [pluginEnum nextObject])) {
456 artwork = [plugin artworkForTitle:track
460 isCompilation:(compilation ? compilation : NO)];
461 if (artwork && [plugin usesNetwork])
462 [archivePlugin archiveImage:artwork track:track artist:artist album:album composer:composer compilation:compilation];
467 if (!error && !![newTrackURL hasPrefix:@"http://"]) {
468 NSLog(@"Error getting artwork: %@", [error objectForKey:NSAppleScriptErrorMessage]);
469 if ([plugins count]) NSLog(@"No plug-ins found anything either, or you wouldn't have this message.");
472 // Use the iTunes icon instead
473 artwork = [[NSWorkspace sharedWorkspace] iconForApplication:@"iTunes"];
474 [artwork setSize:NSMakeSize(128.0f, 128.0f)];
476 if ([newTrackURL hasPrefix:@"http://"]) {
477 //If we're streaming music, display only the name of the station and genre
478 NSLog(@"new track URL: %@", newTrackURL);
479 if (!streamTitle) streamTitle = @"";
480 displayString = [[NSString alloc] initWithFormat:@"%@\n%@", streamTitle, genre];
482 if (!length) length = @"";
483 if (!ratingString) ratingString = @"";
484 if (!album) album = @"";
485 if (!genre) genre = @"";
486 if (!composer) composer = @"";
487 if (!artist) artist = composer;
489 if ([composer length]) {
490 displayString = [[NSString alloc] initWithFormat:NSLocalizedString(@"%@ — %@\n%@ (Composed by %@)\n%@\n%@", "This is the format used for a normal song. In the order shown in English, the parameters are length, rating, artist, composer, album, and genre"), length, ratingString, artist, composer, album, genre];
492 displayString = [[NSString alloc] initWithFormat:NSLocalizedString(@"%@ — %@\n%@\n%@\n%@", "This is the format used for a normal song. In the order shown in English, the parameters are length, rating, artist, album, and genre"), length, ratingString, artist, album, genre];
497 noteDict = [[NSDictionary alloc] initWithObjectsAndKeys:
498 (state == itPLAYING ? ITUNES_TRACK_CHANGED : ITUNES_PLAYING), GROWL_NOTIFICATION_NAME,
499 APP_NAME, GROWL_APP_NAME,
500 track, GROWL_NOTIFICATION_TITLE,
501 displayString, GROWL_NOTIFICATION_DESCRIPTION,
502 APP_NAME, GROWL_NOTIFICATION_IDENTIFIER,
503 [artwork TIFFRepresentation], GROWL_NOTIFICATION_ICON,
505 [displayString release];
507 BOOL URLChanged = [newTrackURL isEqualToString:trackURL];
508 BOOL isStream = [newTrackURL hasPrefix:@"http://"];
509 BOOL descriptionChanged = !(lastPostedDescription && [lastPostedDescription isEqualToString:displayString]);
510 if (URLChanged || (isStream && descriptionChanged)) {
512 [GrowlApplicationBridge notifyWithDictionary:noteDict];
515 if (streamTitle && [streamTitle length]) {
516 //streamed song - insert streamTitle (song name) rather than track (radio name)
517 [self addTuneToRecentTracks:streamTitle fromPlaylist:playlistName];
519 [self addTuneToRecentTracks:track fromPlaylist:playlistName];
523 // set up us some state for next time
526 trackURL = [newTrackURL retain];
527 [lastPostedDescription release];
528 lastPostedDescription = [displayString retain];
532 #pragma mark Poll timer
534 - (void) poll:(NSTimer *)timer {
535 #pragma unused(timer)
536 NSDictionary *error = nil;
537 NSAppleEventDescriptor *theDescriptor = [pollScript executeAndReturnError:&error];
538 NSAppleEventDescriptor *curDescriptor;
539 NSString *playerState;
540 iTunesState newState = itUNKNOWN;
543 curDescriptor = [theDescriptor descriptorAtIndex:1L];
544 playerState = [curDescriptor stringValue];
546 if ([playerState isEqualToString:@"paused"]) {
548 } else if ([playerState isEqualToString:@"stopped"]) {
549 newState = itSTOPPED;
554 newState = itPLAYING;
555 newTrackID = [curDescriptor int32Value];
558 if (state == itUNKNOWN) {
560 trackID = newTrackID;
565 NSString *track = nil;
566 NSString *length = nil;
567 NSString *artist = nil;
568 NSString *album = nil;
569 NSString *composer = nil;
570 BOOL compilation = NO;
571 NSString *genre = nil;
572 NSNumber *rating = nil;
573 NSString *ratingString = nil;
574 NSImage *artwork = nil;
576 curDescriptor = [theDescriptor descriptorAtIndex:10L];
577 playlistName = [curDescriptor stringValue];
579 if ((curDescriptor = [theDescriptor descriptorAtIndex:2L]))
580 track = [curDescriptor stringValue];
582 if ((curDescriptor = [theDescriptor descriptorAtIndex:3L]))
583 length = [curDescriptor stringValue];
585 if ((curDescriptor = [theDescriptor descriptorAtIndex:4L]))
586 artist = [curDescriptor stringValue];
588 if ((curDescriptor = [theDescriptor descriptorAtIndex:5L]))
589 album = [curDescriptor stringValue];
591 if ((curDescriptor = [theDescriptor descriptorAtIndex:6L]))
592 composer = [curDescriptor stringValue];
594 if ((curDescriptor = [theDescriptor descriptorAtIndex:7L]))
595 compilation = (BOOL)[curDescriptor booleanValue];
597 if ((curDescriptor = [theDescriptor descriptorAtIndex:8L]))
598 genre = [curDescriptor stringValue];
600 if ((curDescriptor = [theDescriptor descriptorAtIndex:9L])) {
601 trackRating = [[curDescriptor stringValue] intValue];
602 rating = [NSNumber numberWithInt:trackRating < 0 ? 0 : trackRating];
603 ratingString = [self starsForRating:rating];
606 curDescriptor = [theDescriptor descriptorAtIndex:10L];
607 const OSType type = [curDescriptor typeCodeValue];
609 if (type != 'null') {
610 artwork = [[[NSImage alloc] initWithData:[curDescriptor data]] autorelease];
612 NSEnumerator *pluginEnum = [plugins objectEnumerator];
613 id <GrowlTunesPlugin> plugin;
614 while (!artwork && (plugin = [pluginEnum nextObject])) {
615 artwork = [plugin artworkForTitle:track
619 isCompilation:compilation];
620 if (artwork && [plugin usesNetwork])
621 [archivePlugin archiveImage:artwork track:track artist:artist album:album composer:composer compilation:compilation];
627 NSLog(@"Error getting artwork: %@", [error objectForKey:NSAppleScriptErrorMessage]);
628 if ([plugins count]) NSLog(@"No plug-ins found anything either, or you wouldn't have this message.");
631 // Use the iTunes icon instead
632 artwork = [[NSWorkspace sharedWorkspace] iconForApplication:@"iTunes"];
633 [artwork setSize:NSMakeSize(128.0f, 128.0f)];
636 NSString *description = [[NSString alloc] initWithFormat:@"%@ - %@\n%@ (Composed by %@)\n%@\n%@", length, ratingString, artist, composer, album, genre];
638 noteDict = [[NSDictionary alloc] initWithObjectsAndKeys:
639 (state == itPLAYING ? ITUNES_TRACK_CHANGED : ITUNES_PLAYING), GROWL_NOTIFICATION_NAME,
640 APP_NAME, GROWL_APP_NAME,
641 track, GROWL_NOTIFICATION_TITLE,
642 description, GROWL_NOTIFICATION_DESCRIPTION,
643 APP_NAME, GROWL_NOTIFICATION_IDENTIFIER,
644 [artwork TIFFRepresentation], GROWL_NOTIFICATION_ICON,
646 [description release];
648 if (trackID != newTrackID) { // this is different from previous note
650 [GrowlApplicationBridge notifyWithDictionary:noteDict];
653 [self addTuneToRecentTracks:track fromPlaylist:playlistName];
656 // set up us some state for next time
658 trackID = newTrackID;
662 - (void) showCurrentTrack {
664 [GrowlApplicationBridge notifyWithDictionary:noteDict];
667 - (void) startTimer {
669 pollTimer = [[NSTimer scheduledTimerWithTimeInterval:pollInterval
671 selector:@selector(poll:)
673 repeats:YES] retain];
674 NSLog(@"%@", @"Polling started - upgrade to iTunes 4.7 or later already, would you?!");
681 [pollTimer invalidate];
684 NSLog(@"%@", @"Polling stopped");
688 #pragma mark Status item
690 - (void) createStatusItem {
692 NSStatusBar *statusBar = [NSStatusBar systemStatusBar];
693 statusItem = [[statusBar statusItemWithLength:NSSquareStatusItemLength] retain];
695 [statusItem setMenu:[self statusItemMenu]];
696 [statusItem setHighlightMode:YES];
697 [statusItem setImage:[NSImage imageNamed:@"growlTunes.png"]];
698 [statusItem setAlternateImage:[NSImage imageNamed:@"growlTunes-selected.png"]];
699 [statusItem setToolTip:NSLocalizedString(@"GrowlTunes’ control status item.", /*comment*/ nil)];
704 - (void) tearDownStatusItem {
706 [[NSStatusBar systemStatusBar] removeStatusItem:statusItem]; //otherwise we leave a hole
707 [statusItem release];
712 - (NSMenu *) statusItemMenu {
713 NSMenu *menu = [[NSMenu allocWithZone:[NSMenu menuZone]] initWithTitle:@"GrowlTunes"];
716 NSString *empty = @""; //used for the key equivalent of all the menu items.
718 item = [menu addItemWithTitle:NSLocalizedString(@"Online Help", @"") action:@selector(onlineHelp:) keyEquivalent:empty];
719 [item setTarget:self];
720 [item setTag:onlineHelpTag];
721 [item setToolTip:NSLocalizedString(@"Opens the webpage for GrowlTunes help on the Growl website in your selected browser.", "Online help's tooltip")];
723 item = [NSMenuItem separatorItem];
726 item = [menu addItemWithTitle:@"iTunes" action:NULL keyEquivalent:empty];
728 // Set us up a submenu
729 [item setSubmenu:[self buildiTunesSubmenu]];
731 // The rating submenu
732 item = [menu addItemWithTitle:NSLocalizedString(@"Rating", @"") action:NULL keyEquivalent:empty];
733 [item setSubmenu:[self buildRatingSubmenu]];
735 // Back to our regularly scheduled Status Menu
736 item = [NSMenuItem separatorItem];
739 item = [menu addItemWithTitle:NSLocalizedString(@"Quit GrowlTunes", @"") action:@selector(quitGrowlTunes:) keyEquivalent:empty];
740 [item setTarget:self];
741 [item setTag:quitGrowlTunesTag];
742 item = [menu addItemWithTitle:NSLocalizedString(@"Quit Both", @"") action:@selector(quitBoth:) keyEquivalent:empty];
743 [item setTarget:self];
744 [item setTag:quitBothTag];
745 [item setToolTip:NSLocalizedString(@"Quits both iTunes and GrowlTunes", /*comment*/ nil)];
748 item = [NSMenuItem separatorItem];
751 item = [menu addItemWithTitle:@"Toggle Polling" action:@selector(togglePolling:) keyEquivalent:empty];
752 [item setTarget:self];
753 [item setTag:togglePollingTag];
754 [item setToolTip:NSLocalizedString(@"Turns on or off GrowlTunes' periodic asking of iTunes for track information.", "Toggle polling tooltip")];
758 return [menu autorelease];
761 - (IBAction) togglePolling:(id)sender {
762 #pragma unused(sender)
769 - (NSMenu *) buildiTunesSubmenu {
772 iTunesSubMenu = [[[NSMenu allocWithZone:[NSMenu menuZone]] initWithTitle:@"iTunes"] autorelease];
775 NSArray *items = [iTunesSubMenu itemArray];
776 NSEnumerator *itemEnumerator = [items objectEnumerator];
777 while ((item = [itemEnumerator nextObject]))
778 [iTunesSubMenu removeItem:item];
781 item = [iTunesSubMenu addItemWithTitle:NSLocalizedString(@"Recently Played Tunes", @"") action:NULL keyEquivalent:@""];
782 NSEnumerator *tunesEnumerator = [recentTracks objectEnumerator];
783 NSDictionary *aTuneDict = nil;
786 while ((aTuneDict = [tunesEnumerator nextObject])) {
787 item = [iTunesSubMenu addItemWithTitle:[aTuneDict objectForKey:@"name"]
788 action:@selector(jumpToTune:)
790 [item setTarget:self];
791 [item setIndentationLevel:1];
793 [item setToolTip:NSLocalizedString(@"Tells iTunes to play this track again.", "Tooltip for recent tracks")];
796 [iTunesSubMenu addItem:[NSMenuItem separatorItem]];
797 item = [iTunesSubMenu addItemWithTitle:@"Launch iTunes" action:@selector(launchQuitiTunes:) keyEquivalent:@""];
798 [item setTarget:self];
799 [item setTag:launchQuitiTunesTag];
800 //tooltip set by validateMenuItem
802 return iTunesSubMenu;
805 - (NSMenu *) buildRatingSubmenu {
807 if (!ratingSubMenu) {
808 ratingSubMenu = [[[NSMenu allocWithZone:[NSMenu menuZone]] initWithTitle:@"Rating"] autorelease];
809 NSString *rating0 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86"];
810 NSString *rating1 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86"];
811 NSString *rating2 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86"];
812 NSString *rating3 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x86\xe2\x98\x86"];
813 NSString *rating4 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x86"];
814 NSString *rating5 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85"];
815 item = [ratingSubMenu addItemWithTitle:rating0 action:@selector(setRating:) keyEquivalent:@""];
816 [item setTarget:self];
817 [item setTag:ratingTag+0];
818 item = [ratingSubMenu addItemWithTitle:rating1 action:@selector(setRating:) keyEquivalent:@""];
819 [item setTarget:self];
820 [item setTag:ratingTag+1];
821 item = [ratingSubMenu addItemWithTitle:rating2 action:@selector(setRating:) keyEquivalent:@""];
822 [item setTarget:self];
823 [item setTag:ratingTag+2];
824 item = [ratingSubMenu addItemWithTitle:rating3 action:@selector(setRating:) keyEquivalent:@""];
825 [item setTarget:self];
826 [item setTag:ratingTag+3];
827 item = [ratingSubMenu addItemWithTitle:rating4 action:@selector(setRating:) keyEquivalent:@""];
828 [item setTarget:self];
829 [item setTag:ratingTag+4];
830 item = [ratingSubMenu addItemWithTitle:rating5 action:@selector(setRating:) keyEquivalent:@""];
831 [item setTarget:self];
832 [item setTag:ratingTag+5];
841 return ratingSubMenu;
844 - (BOOL) validateMenuItem:(NSMenuItem *)item {
846 int tag = [item tag];
850 case launchQuitiTunesTag:
851 if ([self iTunesIsRunning])
852 [item setTitle:NSLocalizedString(@"Quit iTunes", @"")];
854 [item setTitle:NSLocalizedString(@"Launch iTunes", @"")];
858 retVal = [self iTunesIsRunning];
861 case togglePollingTag:
863 [item setTitle:NSLocalizedString(@"Stop Polling", @"")];
864 [item setToolTip:NSLocalizedString(@"Stops GrowlTunes from asking iTunes for track information. You will then no longer receive Growl notifications from GrowlTunes.", "Tooltip for 'stop polling'")];
866 [item setTitle:NSLocalizedString(@"Start Polling", @"")];
867 [item setToolTip:NSLocalizedString(@"Begins asking iTunes for track information. You will then start receiving Growl notifications from GrowlTunes.", "Tooltip for 'start polling'")];
870 case quitGrowlTunesTag:
880 i = (tag-ratingTag)*20;
881 if (trackRating < 0) {
883 [item setState:NSOffState];
884 } else if (trackRating >= i && trackRating < i+20)
885 [item setState:NSOnState];
887 [item setState:NSOffState];
894 - (IBAction) onlineHelp:(id)sender{
895 #pragma unused(sender)
896 [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:ONLINE_HELP_URL]];
899 - (void) addTuneToRecentTracks:(NSString *)inTune fromPlaylist:(NSString *)inPlaylist {
900 NSNumber *recentTrackCountNum = [[NSUserDefaults standardUserDefaults] objectForKey:RECENT_TRACK_COUNT_KEY];
901 unsigned trackLimit = recentTrackCountNum ? [recentTrackCountNum unsignedIntValue] : DEFAULT_RECENT_TRACKS_LIMIT;
902 NSDictionary *tuneDict = [[NSDictionary alloc] initWithObjectsAndKeys:
904 inPlaylist, @"playlist",
906 signed long delta = ([recentTracks count] + 1U) - (signed long)trackLimit;
908 [recentTracks removeObjectsInRange:NSMakeRange(0U, delta)];
909 [recentTracks addObject:tuneDict];
912 if (![[NSUserDefaults standardUserDefaults] boolForKey:NO_MENU_KEY])
913 [self buildiTunesSubmenu];
916 - (IBAction) quitGrowlTunes:(id)sender {
917 [NSApp terminate:sender];
920 - (IBAction) launchQuitiTunes:(id)sender {
921 #pragma unused(sender)
922 if (![self quitiTunes]) {
923 //quit failed, so it wasn't running: launch it.
924 [[NSWorkspace sharedWorkspace] launchAppWithBundleIdentifier:ITUNES_BUNDLE_ID
925 options:NSWorkspaceLaunchDefault
926 additionalEventParamDescriptor:nil
927 launchIdentifier:NULL];
931 - (IBAction) quitBoth:(id)sender {
933 [self quitGrowlTunes:sender];
936 - (BOOL) quitiTunes {
937 NSDictionary *iTunes = [[NSWorkspace sharedWorkspace] launchedApplicationWithIdentifier:ITUNES_BUNDLE_ID];
938 BOOL success = (iTunes != nil);
940 //first disarm the timer. we don't want to launch iTunes right after we quit it if the timer fires.
944 NSAppleEventDescriptor *target = [[NSAppleEventDescriptor alloc] initWithDescriptorType:typeApplicationBundleID
945 data:[ITUNES_BUNDLE_ID dataUsingEncoding:NSUTF8StringEncoding]];
946 NSAppleEventDescriptor *event = [[NSAppleEventDescriptor alloc] initWithEventClass:kCoreEventClass
947 eventID:kAEQuitApplication
948 targetDescriptor:target
949 returnID:kAutoGenerateReturnID
950 transactionID:kAnyTransactionID];
951 OSStatus err = AESendMessage([event aeDesc],
953 /*sendMode*/ kAENoReply | kAENeverInteract | kAEDontRecord,
957 success = ((err == noErr) || (err == procNotFound));
958 //XXX this should be an alert panel (with a better message)
960 NSLog(@"Could not quit iTunes: AESendMessage returned %li", (long)err);
965 - (IBAction) setRating:(id)sender {
968 AEDesc currentTrackObject;
969 AEDesc ratingProperty;
970 AEDesc trackDescriptor;
971 AEDesc ratingDescriptor;
974 AEDesc nullDescriptor = {typeNull, nil};
975 DescType trackType = 'pTrk';
976 DescType ratingType = 'pRte';
977 NSData *bundleID = [ITUNES_BUNDLE_ID dataUsingEncoding:NSUTF8StringEncoding];
978 int rating = ([sender tag] - ratingTag) * 20;
980 err = AECreateDesc(typeType, &trackType, sizeof(trackType), &trackDescriptor);
982 NSLog(@"AECreateDesc returned %li", (long)err);
983 err = AECreateDesc(typeType, &ratingType, sizeof(ratingType), &ratingDescriptor);
985 NSLog(@"AECreateDesc returned %li", (long)err);
986 err = AECreateDesc(typeSInt32, &rating, sizeof(rating), &ratingValue);
988 NSLog(@"AECreateDesc returned %li", (long)err);
989 err = AECreateDesc(typeApplicationBundleID, [bundleID bytes], [bundleID length], &target);
991 NSLog(@"AECreateDesc returned %li", (long)err);
993 err = CreateObjSpecifier(typeProperty,
998 ¤tTrackObject);
1000 NSLog(@"CreateObjSpecifier returned %li", (long)err);
1001 err = CreateObjSpecifier(typeProperty,
1002 ¤tTrackObject,
1008 NSLog(@"CreateObjSpecifier returned %li", (long)err);
1010 err = AECreateAppleEvent('core', 'setd', &target, kAutoGenerateReturnID, kAnyTransactionID, &event);
1012 NSLog(@"AECreateAppleEvent returned %li", (long)err);
1013 err = AEPutParamDesc(&event, 'data', &ratingValue);
1015 NSLog(@"AEPutParamDesc returned %li", (long)err);
1016 err = AEPutParamDesc(&event, keyDirectObject, &ratingProperty);
1018 NSLog(@"AEPutParamDesc returned %li", (long)err);
1020 err = AESendMessage(&event,
1022 /*sendMode*/ kAENoReply | kAENeverInteract | kAEDontRecord,
1025 NSLog(@"AESendMessage returned %li", (long)err);
1027 AEDisposeDesc(&event);
1028 AEDisposeDesc(&target);
1029 AEDisposeDesc(&ratingValue);
1030 AEDisposeDesc(&ratingProperty);
1032 trackRating = rating;
1035 #pragma mark AppleScript
1037 - (NSAppleScript *) appleScriptNamed:(NSString *)name {
1038 NSURL *url = [NSURL fileURLWithPath:[[NSBundle mainBundle] pathForResource:name ofType:@"scpt"]];
1039 NSDictionary *error;
1041 return [[NSAppleScript alloc] initWithContentsOfURL:url error:&error];
1044 - (BOOL) iTunesIsRunning {
1045 return [[NSWorkspace sharedWorkspace] launchedApplicationWithIdentifier:ITUNES_BUNDLE_ID] != nil;
1048 - (void) jumpToTune:(id) sender {
1049 NSDictionary *tuneDict = [recentTracks objectAtIndex:[sender tag]];
1050 NSString *jumpScript = [[NSString alloc] initWithFormat:@"tell application \"iTunes\"\nplay track \"%@\" of playlist \"%@\"\nend tell",
1051 [tuneDict objectForKey:@"name"],
1052 [tuneDict objectForKey:@"playlist"]];
1053 NSAppleScript *as = [[NSAppleScript alloc] initWithSource:jumpScript];
1054 [as executeAndReturnError:NULL];
1056 [jumpScript release];
1059 - (void) handleAppLaunch:(NSNotification *)notification {
1060 if ([ITUNES_BUNDLE_ID caseInsensitiveCompare:[[notification userInfo] objectForKey:@"NSApplicationBundleIdentifier"]] == NSOrderedSame)
1064 - (void) handleAppQuit:(NSNotification *)notification {
1065 if ([ITUNES_BUNDLE_ID caseInsensitiveCompare:[[notification userInfo] objectForKey:@"NSApplicationBundleIdentifier"]] == NSOrderedSame)
1069 #pragma mark Plug-ins
1071 // This function is used to sort plugins, trying first the local ones, and then the network ones
1072 static int comparePlugins(id <GrowlTunesPlugin> plugin1, id <GrowlTunesPlugin> plugin2, void *context) {
1073 #pragma unused(context)
1074 BOOL b1 = [plugin1 usesNetwork];
1075 BOOL b2 = [plugin2 usesNetwork];
1076 if (b2 && !b1) //b1 is local; b2 is network
1077 return NSOrderedAscending;
1078 else if (b1 && !b2) //b1 is network; b2 is local
1079 return NSOrderedDescending;
1080 else //both have the same behaviour
1081 return NSOrderedAscending;
1084 - (NSMutableArray *) loadPlugins {
1085 NSMutableArray *newPlugins = [[NSMutableArray alloc] init];
1086 NSMutableArray *lastPlugins = [[NSMutableArray alloc] init];
1088 NSBundle *myBundle = [NSBundle mainBundle];
1089 NSString *pluginsPath = [myBundle builtInPlugInsPath];
1090 NSString *applicationSupportPath = [@"~/Library/Application Support/GrowlTunes/Plugins" stringByExpandingTildeInPath];
1091 NSArray *loadPathsArray = [NSArray arrayWithObjects:pluginsPath, applicationSupportPath, nil];
1092 NSEnumerator *loadPathsEnum = [loadPathsArray objectEnumerator];
1094 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1095 static NSString *pluginPathExtension = @"plugin";
1097 while ((loadPath = [loadPathsEnum nextObject])) {
1098 NSEnumerator *pluginEnum = [[[NSFileManager defaultManager] directoryContentsAtPath:loadPath] objectEnumerator];
1101 while ((curPath = [pluginEnum nextObject])) {
1102 if ([[curPath pathExtension] isEqualToString:pluginPathExtension]) {
1103 curPath = [pluginsPath stringByAppendingPathComponent:curPath];
1104 NSBundle *plugin = [NSBundle bundleWithPath:curPath];
1106 if ([plugin load]) {
1107 Class principalClass = [plugin principalClass];
1109 if ([principalClass conformsToProtocol:@protocol(GrowlTunesPlugin)]) {
1110 id instance = [[principalClass alloc] init];
1111 [newPlugins addObject:instance];
1113 if (!archivePlugin && ([principalClass conformsToProtocol:@protocol(GrowlTunesPluginArchive)])) {
1114 archivePlugin = [instance retain];
1115 // NSLog(@"plug-in %@ is archive-Plugin with id %p", [curPath lastPathComponent], instance);
1118 // NSLog(@"Loaded plug-in \"%@\" with id %p", [curPath lastPathComponent], instance);
1120 NSLog(@"Loaded plug-in \"%@\" does not conform to protocol", [curPath lastPathComponent]);
1122 NSLog(@"Could not load plug-in \"%@\"", [curPath lastPathComponent]);
1128 [newPlugins addObjectsFromArray:lastPlugins];
1129 [lastPlugins release];
1130 [newPlugins autorelease];
1133 // sort the plugins, putting the one that uses network last
1134 return (NSMutableArray *)[newPlugins sortedArrayUsingFunction:comparePlugins context:NULL];
1139 @implementation NSObject(GrowlTunesDummyPlugin)
1141 - (NSImage *) artworkForTitle:(NSString *)track
1142 byArtist:(NSString *)artist
1143 onAlbum:(NSString *)album
1144 isCompilation:(BOOL)compilation
1146 #pragma unused(track,artist,album,compilation)
1147 NSLog(@"Dummy plug-in %p called for artwork", self);
1153 @implementation NSString (GrowlTunesMultiplicationAdditions)
1155 - (NSString *)stringByMultiplyingBy:(NSUInteger)multi {
1156 NSUInteger length = [self length];
1157 NSUInteger length_multi = length * multi;
1159 unichar *buf = malloc(sizeof(unichar) * length_multi);
1163 for (NSUInteger i = 0UL; i < multi; ++i)
1164 [self getCharacters:&buf[length * i]];
1166 NSString *result = [NSString stringWithCharacters:buf length:length_multi];