Patch from Ludek (http://www.dolejsky.com/2009/02/19/growltunes-patch/) which fixes display of radio streams. Thanks!
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:(unsigned)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 if (![newTrackURL isEqualToString:trackURL] || [newTrackURL hasPrefix:@"http://"]) { // this is different from previous notification, or it's a stream
509 [GrowlApplicationBridge notifyWithDictionary:noteDict];
512 if (streamTitle && [streamTitle length]) {
513 //streamed song - insert streamTitle (song name) rather than track (radio name)
514 [self addTuneToRecentTracks:streamTitle fromPlaylist:playlistName];
516 [self addTuneToRecentTracks:track fromPlaylist:playlistName];
520 // set up us some state for next time
523 trackURL = [newTrackURL retain];
527 #pragma mark Poll timer
529 - (void) poll:(NSTimer *)timer {
530 #pragma unused(timer)
531 NSDictionary *error = nil;
532 NSAppleEventDescriptor *theDescriptor = [pollScript executeAndReturnError:&error];
533 NSAppleEventDescriptor *curDescriptor;
534 NSString *playerState;
535 iTunesState newState = itUNKNOWN;
538 curDescriptor = [theDescriptor descriptorAtIndex:1L];
539 playerState = [curDescriptor stringValue];
541 if ([playerState isEqualToString:@"paused"]) {
543 } else if ([playerState isEqualToString:@"stopped"]) {
544 newState = itSTOPPED;
549 newState = itPLAYING;
550 newTrackID = [curDescriptor int32Value];
553 if (state == itUNKNOWN) {
555 trackID = newTrackID;
560 NSString *track = nil;
561 NSString *length = nil;
562 NSString *artist = nil;
563 NSString *album = nil;
564 NSString *composer = nil;
565 BOOL compilation = NO;
566 NSString *genre = nil;
567 NSNumber *rating = nil;
568 NSString *ratingString = nil;
569 NSImage *artwork = nil;
571 curDescriptor = [theDescriptor descriptorAtIndex:10L];
572 playlistName = [curDescriptor stringValue];
574 if ((curDescriptor = [theDescriptor descriptorAtIndex:2L]))
575 track = [curDescriptor stringValue];
577 if ((curDescriptor = [theDescriptor descriptorAtIndex:3L]))
578 length = [curDescriptor stringValue];
580 if ((curDescriptor = [theDescriptor descriptorAtIndex:4L]))
581 artist = [curDescriptor stringValue];
583 if ((curDescriptor = [theDescriptor descriptorAtIndex:5L]))
584 album = [curDescriptor stringValue];
586 if ((curDescriptor = [theDescriptor descriptorAtIndex:6L]))
587 composer = [curDescriptor stringValue];
589 if ((curDescriptor = [theDescriptor descriptorAtIndex:7L]))
590 compilation = (BOOL)[curDescriptor booleanValue];
592 if ((curDescriptor = [theDescriptor descriptorAtIndex:8L]))
593 genre = [curDescriptor stringValue];
595 if ((curDescriptor = [theDescriptor descriptorAtIndex:9L])) {
596 trackRating = [[curDescriptor stringValue] intValue];
597 rating = [NSNumber numberWithInt:trackRating < 0 ? 0 : trackRating];
598 ratingString = [self starsForRating:rating];
601 curDescriptor = [theDescriptor descriptorAtIndex:10L];
602 const OSType type = [curDescriptor typeCodeValue];
604 if (type != 'null') {
605 artwork = [[[NSImage alloc] initWithData:[curDescriptor data]] autorelease];
607 NSEnumerator *pluginEnum = [plugins objectEnumerator];
608 id <GrowlTunesPlugin> plugin;
609 while (!artwork && (plugin = [pluginEnum nextObject])) {
610 artwork = [plugin artworkForTitle:track
614 isCompilation:compilation];
615 if (artwork && [plugin usesNetwork])
616 [archivePlugin archiveImage:artwork track:track artist:artist album:album composer:composer compilation:compilation];
622 NSLog(@"Error getting artwork: %@", [error objectForKey:NSAppleScriptErrorMessage]);
623 if ([plugins count]) NSLog(@"No plug-ins found anything either, or you wouldn't have this message.");
626 // Use the iTunes icon instead
627 artwork = [[NSWorkspace sharedWorkspace] iconForApplication:@"iTunes"];
628 [artwork setSize:NSMakeSize(128.0f, 128.0f)];
631 NSString *description = [[NSString alloc] initWithFormat:@"%@ - %@\n%@ (Composed by %@)\n%@\n%@", length, ratingString, artist, composer, album, genre];
633 noteDict = [[NSDictionary alloc] initWithObjectsAndKeys:
634 (state == itPLAYING ? ITUNES_TRACK_CHANGED : ITUNES_PLAYING), GROWL_NOTIFICATION_NAME,
635 APP_NAME, GROWL_APP_NAME,
636 track, GROWL_NOTIFICATION_TITLE,
637 description, GROWL_NOTIFICATION_DESCRIPTION,
638 APP_NAME, GROWL_NOTIFICATION_IDENTIFIER,
639 [artwork TIFFRepresentation], GROWL_NOTIFICATION_ICON,
641 [description release];
643 if (trackID != newTrackID) { // this is different from previous note
645 [GrowlApplicationBridge notifyWithDictionary:noteDict];
648 [self addTuneToRecentTracks:track fromPlaylist:playlistName];
651 // set up us some state for next time
653 trackID = newTrackID;
657 - (void) showCurrentTrack {
659 [GrowlApplicationBridge notifyWithDictionary:noteDict];
662 - (void) startTimer {
664 pollTimer = [[NSTimer scheduledTimerWithTimeInterval:pollInterval
666 selector:@selector(poll:)
668 repeats:YES] retain];
669 NSLog(@"%@", @"Polling started - upgrade to iTunes 4.7 or later already, would you?!");
676 [pollTimer invalidate];
679 NSLog(@"%@", @"Polling stopped");
683 #pragma mark Status item
685 - (void) createStatusItem {
687 NSStatusBar *statusBar = [NSStatusBar systemStatusBar];
688 statusItem = [[statusBar statusItemWithLength:NSSquareStatusItemLength] retain];
690 [statusItem setMenu:[self statusItemMenu]];
691 [statusItem setHighlightMode:YES];
692 [statusItem setImage:[NSImage imageNamed:@"growlTunes.png"]];
693 [statusItem setAlternateImage:[NSImage imageNamed:@"growlTunes-selected.png"]];
694 [statusItem setToolTip:NSLocalizedString(@"GrowlTunes’ control status item.", /*comment*/ nil)];
699 - (void) tearDownStatusItem {
701 [[NSStatusBar systemStatusBar] removeStatusItem:statusItem]; //otherwise we leave a hole
702 [statusItem release];
707 - (NSMenu *) statusItemMenu {
708 NSMenu *menu = [[NSMenu allocWithZone:[NSMenu menuZone]] initWithTitle:@"GrowlTunes"];
711 NSString *empty = @""; //used for the key equivalent of all the menu items.
713 item = [menu addItemWithTitle:NSLocalizedString(@"Online Help", @"") action:@selector(onlineHelp:) keyEquivalent:empty];
714 [item setTarget:self];
715 [item setTag:onlineHelpTag];
716 [item setToolTip:NSLocalizedString(@"Opens the webpage for GrowlTunes help on the Growl website in your selected browser.", "Online help's tooltip")];
718 item = [NSMenuItem separatorItem];
721 item = [menu addItemWithTitle:@"iTunes" action:NULL keyEquivalent:empty];
723 // Set us up a submenu
724 [item setSubmenu:[self buildiTunesSubmenu]];
726 // The rating submenu
727 item = [menu addItemWithTitle:NSLocalizedString(@"Rating", @"") action:NULL keyEquivalent:empty];
728 [item setSubmenu:[self buildRatingSubmenu]];
730 // Back to our regularly scheduled Status Menu
731 item = [NSMenuItem separatorItem];
734 item = [menu addItemWithTitle:NSLocalizedString(@"Quit GrowlTunes", @"") action:@selector(quitGrowlTunes:) keyEquivalent:empty];
735 [item setTarget:self];
736 [item setTag:quitGrowlTunesTag];
737 item = [menu addItemWithTitle:NSLocalizedString(@"Quit Both", @"") action:@selector(quitBoth:) keyEquivalent:empty];
738 [item setTarget:self];
739 [item setTag:quitBothTag];
740 [item setToolTip:NSLocalizedString(@"Quits both iTunes and GrowlTunes", /*comment*/ nil)];
743 item = [NSMenuItem separatorItem];
746 item = [menu addItemWithTitle:@"Toggle Polling" action:@selector(togglePolling:) keyEquivalent:empty];
747 [item setTarget:self];
748 [item setTag:togglePollingTag];
749 [item setToolTip:NSLocalizedString(@"Turns on or off GrowlTunes' periodic asking of iTunes for track information.", "Toggle polling tooltip")];
753 return [menu autorelease];
756 - (IBAction) togglePolling:(id)sender {
757 #pragma unused(sender)
764 - (NSMenu *) buildiTunesSubmenu {
767 iTunesSubMenu = [[[NSMenu allocWithZone:[NSMenu menuZone]] initWithTitle:@"iTunes"] autorelease];
770 NSArray *items = [iTunesSubMenu itemArray];
771 NSEnumerator *itemEnumerator = [items objectEnumerator];
772 while ((item = [itemEnumerator nextObject]))
773 [iTunesSubMenu removeItem:item];
776 item = [iTunesSubMenu addItemWithTitle:NSLocalizedString(@"Recently Played Tunes", @"") action:NULL keyEquivalent:@""];
777 NSEnumerator *tunesEnumerator = [recentTracks objectEnumerator];
778 NSDictionary *aTuneDict = nil;
781 while ((aTuneDict = [tunesEnumerator nextObject])) {
782 item = [iTunesSubMenu addItemWithTitle:[aTuneDict objectForKey:@"name"]
783 action:@selector(jumpToTune:)
785 [item setTarget:self];
786 [item setIndentationLevel:1];
788 [item setToolTip:NSLocalizedString(@"Tells iTunes to play this track again.", "Tooltip for recent tracks")];
791 [iTunesSubMenu addItem:[NSMenuItem separatorItem]];
792 item = [iTunesSubMenu addItemWithTitle:@"Launch iTunes" action:@selector(launchQuitiTunes:) keyEquivalent:@""];
793 [item setTarget:self];
794 [item setTag:launchQuitiTunesTag];
795 //tooltip set by validateMenuItem
797 return iTunesSubMenu;
800 - (NSMenu *) buildRatingSubmenu {
802 if (!ratingSubMenu) {
803 ratingSubMenu = [[[NSMenu allocWithZone:[NSMenu menuZone]] initWithTitle:@"Rating"] autorelease];
804 NSString *rating0 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86"];
805 NSString *rating1 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86"];
806 NSString *rating2 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x86\xe2\x98\x86\xe2\x98\x86"];
807 NSString *rating3 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x86\xe2\x98\x86"];
808 NSString *rating4 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x86"];
809 NSString *rating5 = [[NSString alloc] initWithUTF8String:"\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85\xe2\x98\x85"];
810 item = [ratingSubMenu addItemWithTitle:rating0 action:@selector(setRating:) keyEquivalent:@""];
811 [item setTarget:self];
812 [item setTag:ratingTag+0];
813 item = [ratingSubMenu addItemWithTitle:rating1 action:@selector(setRating:) keyEquivalent:@""];
814 [item setTarget:self];
815 [item setTag:ratingTag+1];
816 item = [ratingSubMenu addItemWithTitle:rating2 action:@selector(setRating:) keyEquivalent:@""];
817 [item setTarget:self];
818 [item setTag:ratingTag+2];
819 item = [ratingSubMenu addItemWithTitle:rating3 action:@selector(setRating:) keyEquivalent:@""];
820 [item setTarget:self];
821 [item setTag:ratingTag+3];
822 item = [ratingSubMenu addItemWithTitle:rating4 action:@selector(setRating:) keyEquivalent:@""];
823 [item setTarget:self];
824 [item setTag:ratingTag+4];
825 item = [ratingSubMenu addItemWithTitle:rating5 action:@selector(setRating:) keyEquivalent:@""];
826 [item setTarget:self];
827 [item setTag:ratingTag+5];
836 return ratingSubMenu;
839 - (BOOL) validateMenuItem:(NSMenuItem *)item {
841 int tag = [item tag];
845 case launchQuitiTunesTag:
846 if ([self iTunesIsRunning])
847 [item setTitle:NSLocalizedString(@"Quit iTunes", @"")];
849 [item setTitle:NSLocalizedString(@"Launch iTunes", @"")];
853 retVal = [self iTunesIsRunning];
856 case togglePollingTag:
858 [item setTitle:NSLocalizedString(@"Stop Polling", @"")];
859 [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'")];
861 [item setTitle:NSLocalizedString(@"Start Polling", @"")];
862 [item setToolTip:NSLocalizedString(@"Begins asking iTunes for track information. You will then start receiving Growl notifications from GrowlTunes.", "Tooltip for 'start polling'")];
865 case quitGrowlTunesTag:
875 i = (tag-ratingTag)*20;
876 if (trackRating < 0) {
878 [item setState:NSOffState];
879 } else if (trackRating >= i && trackRating < i+20)
880 [item setState:NSOnState];
882 [item setState:NSOffState];
889 - (IBAction) onlineHelp:(id)sender{
890 #pragma unused(sender)
891 [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:ONLINE_HELP_URL]];
894 - (void) addTuneToRecentTracks:(NSString *)inTune fromPlaylist:(NSString *)inPlaylist {
895 NSNumber *recentTrackCountNum = [[NSUserDefaults standardUserDefaults] objectForKey:RECENT_TRACK_COUNT_KEY];
896 unsigned trackLimit = recentTrackCountNum ? [recentTrackCountNum unsignedIntValue] : DEFAULT_RECENT_TRACKS_LIMIT;
897 NSDictionary *tuneDict = [[NSDictionary alloc] initWithObjectsAndKeys:
899 inPlaylist, @"playlist",
901 signed long delta = ([recentTracks count] + 1U) - (signed long)trackLimit;
903 [recentTracks removeObjectsInRange:NSMakeRange(0U, delta)];
904 [recentTracks addObject:tuneDict];
907 if (![[NSUserDefaults standardUserDefaults] boolForKey:NO_MENU_KEY])
908 [self buildiTunesSubmenu];
911 - (IBAction) quitGrowlTunes:(id)sender {
912 [NSApp terminate:sender];
915 - (IBAction) launchQuitiTunes:(id)sender {
916 #pragma unused(sender)
917 if (![self quitiTunes]) {
918 //quit failed, so it wasn't running: launch it.
919 [[NSWorkspace sharedWorkspace] launchAppWithBundleIdentifier:ITUNES_BUNDLE_ID
920 options:NSWorkspaceLaunchDefault
921 additionalEventParamDescriptor:nil
922 launchIdentifier:NULL];
926 - (IBAction) quitBoth:(id)sender {
928 [self quitGrowlTunes:sender];
931 - (BOOL) quitiTunes {
932 NSDictionary *iTunes = [[NSWorkspace sharedWorkspace] launchedApplicationWithIdentifier:ITUNES_BUNDLE_ID];
933 BOOL success = (iTunes != nil);
935 //first disarm the timer. we don't want to launch iTunes right after we quit it if the timer fires.
939 NSAppleEventDescriptor *target = [[NSAppleEventDescriptor alloc] initWithDescriptorType:typeApplicationBundleID
940 data:[ITUNES_BUNDLE_ID dataUsingEncoding:NSUTF8StringEncoding]];
941 NSAppleEventDescriptor *event = [[NSAppleEventDescriptor alloc] initWithEventClass:kCoreEventClass
942 eventID:kAEQuitApplication
943 targetDescriptor:target
944 returnID:kAutoGenerateReturnID
945 transactionID:kAnyTransactionID];
946 OSStatus err = AESendMessage([event aeDesc],
948 /*sendMode*/ kAENoReply | kAENeverInteract | kAEDontRecord,
952 success = ((err == noErr) || (err == procNotFound));
953 //XXX this should be an alert panel (with a better message)
955 NSLog(@"Could not quit iTunes: AESendMessage returned %li", (long)err);
960 - (IBAction) setRating:(id)sender {
963 AEDesc currentTrackObject;
964 AEDesc ratingProperty;
965 AEDesc trackDescriptor;
966 AEDesc ratingDescriptor;
969 AEDesc nullDescriptor = {typeNull, nil};
970 DescType trackType = 'pTrk';
971 DescType ratingType = 'pRte';
972 NSData *bundleID = [ITUNES_BUNDLE_ID dataUsingEncoding:NSUTF8StringEncoding];
973 int rating = ([sender tag] - ratingTag) * 20;
975 err = AECreateDesc(typeType, &trackType, sizeof(trackType), &trackDescriptor);
977 NSLog(@"AECreateDesc returned %li", (long)err);
978 err = AECreateDesc(typeType, &ratingType, sizeof(ratingType), &ratingDescriptor);
980 NSLog(@"AECreateDesc returned %li", (long)err);
981 err = AECreateDesc(typeSInt32, &rating, sizeof(rating), &ratingValue);
983 NSLog(@"AECreateDesc returned %li", (long)err);
984 err = AECreateDesc(typeApplicationBundleID, [bundleID bytes], [bundleID length], &target);
986 NSLog(@"AECreateDesc returned %li", (long)err);
988 err = CreateObjSpecifier(typeProperty,
993 ¤tTrackObject);
995 NSLog(@"CreateObjSpecifier returned %li", (long)err);
996 err = CreateObjSpecifier(typeProperty,
1003 NSLog(@"CreateObjSpecifier returned %li", (long)err);
1005 err = AECreateAppleEvent('core', 'setd', &target, kAutoGenerateReturnID, kAnyTransactionID, &event);
1007 NSLog(@"AECreateAppleEvent returned %li", (long)err);
1008 err = AEPutParamDesc(&event, 'data', &ratingValue);
1010 NSLog(@"AEPutParamDesc returned %li", (long)err);
1011 err = AEPutParamDesc(&event, keyDirectObject, &ratingProperty);
1013 NSLog(@"AEPutParamDesc returned %li", (long)err);
1015 err = AESendMessage(&event,
1017 /*sendMode*/ kAENoReply | kAENeverInteract | kAEDontRecord,
1020 NSLog(@"AESendMessage returned %li", (long)err);
1022 AEDisposeDesc(&event);
1023 AEDisposeDesc(&target);
1024 AEDisposeDesc(&ratingValue);
1025 AEDisposeDesc(&ratingProperty);
1027 trackRating = rating;
1030 #pragma mark AppleScript
1032 - (NSAppleScript *) appleScriptNamed:(NSString *)name {
1033 NSURL *url = [NSURL fileURLWithPath:[[NSBundle mainBundle] pathForResource:name ofType:@"scpt"]];
1034 NSDictionary *error;
1036 return [[NSAppleScript alloc] initWithContentsOfURL:url error:&error];
1039 - (BOOL) iTunesIsRunning {
1040 return [[NSWorkspace sharedWorkspace] launchedApplicationWithIdentifier:ITUNES_BUNDLE_ID] != nil;
1043 - (void) jumpToTune:(id) sender {
1044 NSDictionary *tuneDict = [recentTracks objectAtIndex:[sender tag]];
1045 NSString *jumpScript = [[NSString alloc] initWithFormat:@"tell application \"iTunes\"\nplay track \"%@\" of playlist \"%@\"\nend tell",
1046 [tuneDict objectForKey:@"name"],
1047 [tuneDict objectForKey:@"playlist"]];
1048 NSAppleScript *as = [[NSAppleScript alloc] initWithSource:jumpScript];
1049 [as executeAndReturnError:NULL];
1051 [jumpScript release];
1054 - (void) handleAppLaunch:(NSNotification *)notification {
1055 if ([ITUNES_BUNDLE_ID caseInsensitiveCompare:[[notification userInfo] objectForKey:@"NSApplicationBundleIdentifier"]] == NSOrderedSame)
1059 - (void) handleAppQuit:(NSNotification *)notification {
1060 if ([ITUNES_BUNDLE_ID caseInsensitiveCompare:[[notification userInfo] objectForKey:@"NSApplicationBundleIdentifier"]] == NSOrderedSame)
1064 #pragma mark Plug-ins
1066 // This function is used to sort plugins, trying first the local ones, and then the network ones
1067 static int comparePlugins(id <GrowlTunesPlugin> plugin1, id <GrowlTunesPlugin> plugin2, void *context) {
1068 #pragma unused(context)
1069 BOOL b1 = [plugin1 usesNetwork];
1070 BOOL b2 = [plugin2 usesNetwork];
1071 if (b2 && !b1) //b1 is local; b2 is network
1072 return NSOrderedAscending;
1073 else if (b1 && !b2) //b1 is network; b2 is local
1074 return NSOrderedDescending;
1075 else //both have the same behaviour
1076 return NSOrderedAscending;
1079 - (NSMutableArray *) loadPlugins {
1080 NSMutableArray *newPlugins = [[NSMutableArray alloc] init];
1081 NSMutableArray *lastPlugins = [[NSMutableArray alloc] init];
1083 NSBundle *myBundle = [NSBundle mainBundle];
1084 NSString *pluginsPath = [myBundle builtInPlugInsPath];
1085 NSString *applicationSupportPath = [@"~/Library/Application Support/GrowlTunes/Plugins" stringByExpandingTildeInPath];
1086 NSArray *loadPathsArray = [NSArray arrayWithObjects:pluginsPath, applicationSupportPath, nil];
1087 NSEnumerator *loadPathsEnum = [loadPathsArray objectEnumerator];
1089 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1090 static NSString *pluginPathExtension = @"plugin";
1092 while ((loadPath = [loadPathsEnum nextObject])) {
1093 NSEnumerator *pluginEnum = [[[NSFileManager defaultManager] directoryContentsAtPath:loadPath] objectEnumerator];
1096 while ((curPath = [pluginEnum nextObject])) {
1097 if ([[curPath pathExtension] isEqualToString:pluginPathExtension]) {
1098 curPath = [pluginsPath stringByAppendingPathComponent:curPath];
1099 NSBundle *plugin = [NSBundle bundleWithPath:curPath];
1101 if ([plugin load]) {
1102 Class principalClass = [plugin principalClass];
1104 if ([principalClass conformsToProtocol:@protocol(GrowlTunesPlugin)]) {
1105 id instance = [[principalClass alloc] init];
1106 [newPlugins addObject:instance];
1108 if (!archivePlugin && ([principalClass conformsToProtocol:@protocol(GrowlTunesPluginArchive)])) {
1109 archivePlugin = [instance retain];
1110 // NSLog(@"plug-in %@ is archive-Plugin with id %p", [curPath lastPathComponent], instance);
1113 // NSLog(@"Loaded plug-in \"%@\" with id %p", [curPath lastPathComponent], instance);
1115 NSLog(@"Loaded plug-in \"%@\" does not conform to protocol", [curPath lastPathComponent]);
1117 NSLog(@"Could not load plug-in \"%@\"", [curPath lastPathComponent]);
1123 [newPlugins addObjectsFromArray:lastPlugins];
1124 [lastPlugins release];
1125 [newPlugins autorelease];
1128 // sort the plugins, putting the one that uses network last
1129 return (NSMutableArray *)[newPlugins sortedArrayUsingFunction:comparePlugins context:NULL];
1134 @implementation NSObject(GrowlTunesDummyPlugin)
1136 - (NSImage *) artworkForTitle:(NSString *)track
1137 byArtist:(NSString *)artist
1138 onAlbum:(NSString *)album
1139 isCompilation:(BOOL)compilation
1141 #pragma unused(track,artist,album,compilation)
1142 NSLog(@"Dummy plug-in %p called for artwork", self);
1148 @implementation NSString (GrowlTunesMultiplicationAdditions)
1150 - (NSString *)stringByMultiplyingBy:(unsigned)multi {
1151 unsigned length = [self length];
1152 unsigned length_multi = length * multi;
1154 unichar *buf = malloc(sizeof(unichar) * length_multi);
1158 for (unsigned i = 0U; i < multi; ++i)
1159 [self getCharacters:&buf[length * i]];
1161 NSString *result = [NSString stringWithCharacters:buf length:length_multi];