source: trunk/Cocoa/Pester/Source/NJRQTMediaPopUpButton.m@ 536

Last change on this file since 536 was 535, checked in by Nicholas Riley, 15 years ago

NJRQTMediaPopUpButton.m: Don't crash when sound finishes during processing of new sound.

File size: 20.9 KB
Line 
1//
2// NJRQTMediaPopUpButton.m
3// Pester
4//
5// Created by Nicholas Riley on Sat Oct 26 2002.
6// Copyright (c) 2002 Nicholas Riley. All rights reserved.
7//
8
9#import "NJRQTMediaPopUpButton.h"
10#import "NJRSoundManager.h"
11#import "NSMovie-NJRExtensions.h"
12#import "NSMenuItem-NJRExtensions.h"
13
14#import <QTKit/QTKit.h>
15
16#include <limits.h>
17
18static const int NJRQTMediaPopUpButtonMaxRecentItems = 10;
19
20NSString * const NJRQTMediaPopUpButtonMovieChangedNotification = @"NJRQTMediaPopUpButtonMovieChangedNotification";
21
22@interface NJRQTMediaPopUpButton (Private)
23- (void)_setPath:(NSString *)path;
24- (NSMenuItem *)_itemForAlias:(BDAlias *)alias;
25- (BOOL)_validateWithPreview:(BOOL)doPreview;
26- (void)_startSoundPreview;
27- (void)_resetPreview;
28- (void)_resetOutputVolume;
29@end
30
31@implementation NJRQTMediaPopUpButton
32
33// XXX handle refreshing sound list on resume
34// XXX don't add icons on Puma, they look like ass
35// XXX launch preview on a separate thread (if movies take too long to load, they inhibit the interface responsiveness)
36
37// Recent media layout:
38// Most recent media are at TOP of menu (smaller item numbers, starting at [self indexOfItem: otherItem] + 1)
39// Most recent media are at END of array (larger indices)
40
41#pragma mark recently selected media tracking
42
43- (NSString *)_defaultKey;
44{
45 NSAssert([self tag] != 0, NSLocalizedString(@"Can't track recently selected media for popup with tag 0: please set a tag", "Assertion for QuickTime media popup button if tag is 0"));
46 return [NSString stringWithFormat: @"NJRQTMediaPopUpButtonMaxRecentItems tag %d", [self tag]];
47}
48
49- (void)_writeRecentMedia;
50{
51 NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
52 [defaults setObject: recentMediaAliasData forKey: [self _defaultKey]];
53 [defaults synchronize];
54}
55
56- (NSMenuItem *)_addRecentMediaAtPath:(NSString *)path withAlias:(BDAlias *)alias;
57{
58 NSString *title = [[NSFileManager defaultManager] displayNameAtPath: path];
59 NSMenu *menu = [self menu];
60 NSMenuItem *item;
61 if (title == nil || path == nil) return nil;
62 item = [menu insertItemWithTitle: title action: @selector(_aliasSelected:) keyEquivalent: @"" atIndex: [menu indexOfItem: otherItem] + 1];
63 [item setTarget: self];
64 [item setRepresentedObject: alias];
65 [item setImageFromPath: path];
66 [recentMediaAliasData addObject: [alias aliasData]];
67 if ([recentMediaAliasData count] > NJRQTMediaPopUpButtonMaxRecentItems) {
68 [menu removeItemAtIndex: [menu numberOfItems] - 1];
69 [recentMediaAliasData removeObjectAtIndex: 0];
70 }
71 return item;
72}
73
74- (void)_addRecentMediaFromAliasesData:(NSArray *)aliasesData;
75{
76 NSEnumerator *e = [aliasesData objectEnumerator];
77 NSData *aliasData;
78 BDAlias *alias;
79 while ( (aliasData = [e nextObject]) != nil) {
80 if ( (alias = [[BDAlias alloc] initWithData: aliasData]) != nil) {
81 [self _addRecentMediaAtPath: [alias fullPath] withAlias: alias];
82 [alias release];
83 }
84 }
85}
86
87- (void)_validateRecentMedia;
88{
89 NSEnumerator *e = [recentMediaAliasData reverseObjectEnumerator];
90 NSData *aliasData;
91 NSMenuItem *item;
92 BDAlias *itemAlias;
93 int otherIndex = [self indexOfItem: otherItem];
94 int aliasDataCount = [recentMediaAliasData count];
95 int lastItemIndex = [self numberOfItems] - 1;
96 int recentItemCount = lastItemIndex - otherIndex;
97 int recentItemIndex = otherIndex;
98 NSAssert2(recentItemCount == aliasDataCount, @"Counted %d recent menu items, %d of alias data", recentItemCount, aliasDataCount);
99 while ( (aliasData = [e nextObject]) != nil) { // go BACKWARD through array while going DOWN menu
100 recentItemIndex++;
101 item = [self itemAtIndex: recentItemIndex];
102 itemAlias = [item representedObject];
103 if ([itemAlias aliasDataIsEqual: aliasData])
104 NSLog(@"item %d %@: %@", recentItemIndex, [item title], [itemAlias fullPath]);
105 else
106 NSLog(@"ITEM %d %@: %@ != aliasData %@", recentItemIndex, [item title], [itemAlias fullPath], [[BDAlias aliasWithData: aliasData] fullPath]);
107 }
108}
109
110#pragma mark initialize-release
111
112- (void)_setUp;
113{
114 NSMenu *menu = [self menu];
115 [self removeAllItems];
116 [menu setAutoenablesItems: NO];
117
118 NSMenuItem *item = [menu addItemWithTitle: @"Alert sound" action: @selector(_beepSelected:) keyEquivalent: @""];
119 [item setTarget: self];
120 [menu addItem: [NSMenuItem separatorItem]];
121
122 NSMutableArray *soundFolderPaths = [[NSMutableArray alloc] initWithCapacity: kLastDomainConstant - kSystemDomain + 1];
123 for (FSVolumeRefNum domain = kSystemDomain ; domain <= kLastDomainConstant ; domain++) {
124 OSStatus err;
125 FSRef fsr;
126 err = FSFindFolder(domain, kSystemSoundsFolderType, false, &fsr);
127 if (err != noErr) continue;
128
129 UInt8 path[PATH_MAX];
130 err = FSRefMakePath(&fsr, path, PATH_MAX);
131 if (err != noErr) continue;
132
133 CFStringRef pathString = CFStringCreateWithFileSystemRepresentation(NULL, (const char *)path);
134 if (pathString == NULL) continue;
135
136 [soundFolderPaths addObject: (NSString *)pathString];
137 CFRelease(pathString);
138 }
139 NSFileManager *fm = [NSFileManager defaultManager];
140 NSEnumerator *e = [soundFolderPaths objectEnumerator];
141 NSString *folderPath;
142 while ( (folderPath = [e nextObject]) != nil) {
143 if (![fm changeCurrentDirectoryPath: folderPath]) continue;
144
145 NSDirectoryEnumerator *de = [fm enumeratorAtPath: folderPath];
146 NSString *path;
147 while ( (path = [de nextObject]) != nil) {
148 BOOL isDir;
149 if (![fm fileExistsAtPath: path isDirectory: &isDir] || isDir) {
150 [de skipDescendents];
151 continue;
152 }
153
154 if (![QTMovie canInitWithFile: path]) continue;
155
156 item = [menu addItemWithTitle: [fm displayNameAtPath: path]
157 action: @selector(_systemSoundSelected:)
158 keyEquivalent: @""];
159 [item setTarget: self];
160 [item setImageFromPath: path];
161 path = [folderPath stringByAppendingPathComponent: path];
162 [item setRepresentedObject: path];
163 [item setToolTip: path];
164 }
165 }
166 [soundFolderPaths release];
167
168 if ([menu numberOfItems] == 2) {
169 item = [menu addItemWithTitle: NSLocalizedString(@"Can't locate alert sounds", "QuickTime media popup menu item surrogate for alert sound list if no sounds are found") action: nil keyEquivalent: @""];
170 [item setEnabled: NO];
171 }
172
173 [menu addItem: [NSMenuItem separatorItem]];
174 item = [menu addItemWithTitle: NSLocalizedString(@"Other...", "Media popup item to select another sound/movie/image") action: @selector(select:) keyEquivalent: @""];
175 [item setTarget: self];
176 otherItem = [item retain];
177
178 [self _validateWithPreview: NO];
179
180 recentMediaAliasData = [[NSMutableArray alloc] initWithCapacity: NJRQTMediaPopUpButtonMaxRecentItems + 1];
181 [self _addRecentMediaFromAliasesData: [[NSUserDefaults standardUserDefaults] arrayForKey: [self _defaultKey]]];
182 // [self _validateRecentMedia];
183
184 [self registerForDraggedTypes:
185 [NSArray arrayWithObjects: NSFilenamesPboardType, NSURLPboardType, nil]];
186}
187
188- (id)initWithFrame:(NSRect)frame;
189{
190 if ( (self = [super initWithFrame: frame]) != nil) {
191 [self _setUp];
192 }
193 return self;
194}
195
196- (id)initWithCoder:(NSCoder *)coder;
197{
198 if ( (self = [super initWithCoder: coder]) != nil) {
199 [self _setUp];
200 }
201 return self;
202}
203
204- (void)dealloc;
205{
206 [recentMediaAliasData release]; recentMediaAliasData = nil;
207 [otherItem release];
208 [selectedAlias release]; [previousAlias release];
209 [super dealloc];
210}
211
212#pragma mark accessing
213
214- (BDAlias *)selectedAlias;
215{
216 return selectedAlias;
217}
218
219- (void)_setAlias:(BDAlias *)alias;
220{
221 BDAlias *oldAlias = [selectedAlias retain];
222 [previousAlias release];
223 previousAlias = oldAlias;
224 if (selectedAlias != alias) {
225 [selectedAlias release];
226 selectedAlias = [alias retain];
227 }
228}
229
230- (void)setAlias:(BDAlias *)alias;
231{
232 [self _setAlias: alias];
233 if ([self _validateWithPreview: NO]) {
234 [self selectItem: [self _itemForAlias: selectedAlias]];
235 }
236}
237
238- (void)_setPath:(NSString *)path;
239{
240 [self _setAlias: [BDAlias aliasWithPath: path]];
241}
242
243- (NSMenuItem *)_itemForAlias:(BDAlias *)alias;
244{
245 if (alias == nil) return [self itemAtIndex: 0];
246
247 // [self _validateRecentMedia];
248 NSString *path = [alias fullPath];
249
250 // selected a system sound?
251 int itemIndex = [[self menu] indexOfItemWithRepresentedObject: path];
252 if (itemIndex != -1) {
253 // NSLog(@"_itemForAlias: selected system sound");
254 return [self itemAtIndex: itemIndex];
255 } else {
256 NSEnumerator *e = [recentMediaAliasData reverseObjectEnumerator];
257 NSData *aliasData;
258 NSMenuItem *item;
259 int recentIndex = 1;
260
261 while ( (aliasData = [e nextObject]) != nil) {
262 // selected a recently selected, non-system sound?
263 if ([alias aliasDataIsEqual: aliasData]) {
264 int otherIndex = [self indexOfItem: otherItem];
265 int menuIndex = recentIndex + otherIndex;
266 if (menuIndex == otherIndex + 1) return [self itemAtIndex: menuIndex]; // already at top
267 // remove item, add (at top) later
268 // NSLog(@"_itemForAlias removing item: idx %d + otherItemIdx %d + 1 = %d [%@]", recentIndex, otherIndex, menuIndex, [self itemAtIndex: menuIndex]);
269 [self removeItemAtIndex: menuIndex];
270 [recentMediaAliasData removeObjectAtIndex: [recentMediaAliasData count] - recentIndex];
271 break;
272 }
273 recentIndex++;
274 }
275
276 // create the item
277 item = [self _addRecentMediaAtPath: path withAlias: alias];
278 [self _writeRecentMedia];
279 return item;
280 }
281}
282
283- (BOOL)canRepeat;
284{
285 return movieCanRepeat;
286}
287
288- (BOOL)hasAudio;
289{
290 return movieHasAudio;
291}
292
293- (float)outputVolume;
294{
295 return outputVolume;
296}
297
298- (void)setOutputVolume:(float)volume withPreview:(BOOL)doPreview;
299{
300 if (![NJRSoundManager volumeIsNotMutedOrInvalid: volume]) return;
301 outputVolume = volume;
302 if (!doPreview) return;
303 // NSLog(@"setting volume to %f, preview movie %@", volume, [preview movie]);
304 if ([preview movie] == nil) {
305 [self _validateWithPreview: YES];
306 } else {
307 [self _startSoundPreview];
308 }
309}
310
311#pragma mark selected media validation
312
313- (void)_invalidateSelection;
314{
315 [self _setAlias: previousAlias];
316 [self selectItem: [self _itemForAlias: [self selectedAlias]]];
317 [[NSNotificationCenter defaultCenter] postNotificationName: NJRQTMediaPopUpButtonMovieChangedNotification object: self];
318}
319
320- (void)_startSoundPreview;
321{
322 if ([preview movie] == nil || outputVolume == kNoVolume)
323 return;
324
325 if (savedVolume || [NJRSoundManager saveDefaultOutputVolume]) {
326 savedVolume = YES;
327 [NJRSoundManager setDefaultOutputVolume: outputVolume];
328 }
329
330 if ([[preview movie] rate] != 0)
331 return; // don't restart preview if already playing
332
333 [[NSNotificationCenter defaultCenter] addObserver: self
334 selector: @selector(_soundPreviewDidEnd:)
335 name: QTMovieDidEndNotification
336 object: [preview movie]];
337 [preview play: self];
338}
339
340- (void)_soundPreviewDidEnd:(NSNotification *)notification;
341{
342 [self _resetPreview];
343}
344
345- (void)_resetPreview;
346{
347 [preview setMovie: nil];
348 [self _resetOutputVolume];
349}
350
351- (void)_resetOutputVolume;
352{
353 [NJRSoundManager restoreSavedDefaultOutputVolumeIfCurrently: outputVolume];
354 savedVolume = NO;
355}
356
357- (BOOL)_validateWithPreview:(BOOL)doPreview;
358{
359 // prevent _resetPreview from triggering afterward (crashes)
360 [[NSNotificationCenter defaultCenter] removeObserver: self
361 name: QTMovieDidEndNotification
362 object: [preview movie]];
363 [preview pause: self];
364 if (selectedAlias == nil) {
365 [preview setMovie: nil];
366 movieCanRepeat = YES;
367 movieHasAudio = NO; // XXX should be YES - this is broken, NSBeep() is asynchronous
368 if (doPreview) {
369 // XXX [self _updateOutputVolume];
370 NSBeep();
371 // XXX [self _resetOutputVolume];
372 }
373 } else {
374 NSMovie *movie = [[NSMovie alloc] initWithURL: [NSURL fileURLWithPath: [selectedAlias fullPath]] byReference: YES];
375 movieCanRepeat = ![movie isStatic];
376 if (movieHasAudio = [movie hasAudio]) {
377 [preview setMovie: doPreview ? [QTMovie movieWithURL: [NSURL fileURLWithPath: [selectedAlias fullPath]] error: NULL] : nil]; // XXX handle errors; fix
378 } else {
379 [self _resetPreview];
380 doPreview = NO;
381 if (movie == nil) {
382 NSBeginAlertSheet(@"Format not recognized", nil, nil, nil, [self window], nil, nil, nil, nil, NSLocalizedString(@"The item you selected isn't a sound or movie recognized by QuickTime. Please select a different item.", "Message displayed in alert sheet when media document is not recognized by QuickTime"));
383 [self _invalidateSelection];
384 return NO;
385 }
386 if (![movie hasAudio] && ![movie hasVideo]) {
387 NSBeginAlertSheet(@"No video or audio", nil, nil, nil, [self window], nil, nil, nil, nil, NSLocalizedString(@"'%@' contains neither audio nor video content playable by QuickTime. Please select a different item.", "Message displayed in alert sheet when media document is readable, but has neither audio nor video tracks"), [[NSFileManager defaultManager] displayNameAtPath: [selectedAlias fullPath]]);
388 [self _invalidateSelection];
389 [movie release];
390 return NO;
391 }
392 }
393 if (doPreview) {
394 [self _startSoundPreview];
395 }
396 [movie release];
397 }
398 [[NSNotificationCenter defaultCenter] postNotificationName: NJRQTMediaPopUpButtonMovieChangedNotification object: self];
399 return YES;
400}
401
402#pragma mark actions
403
404- (IBAction)stopSoundPreview:(id)sender;
405{
406 [preview pause: self];
407 [self _resetPreview];
408}
409
410- (void)_beepSelected:(NSMenuItem *)sender;
411{
412 [self _setAlias: nil];
413 [self _validateWithPreview: YES];
414}
415
416- (void)_systemSoundSelected:(NSMenuItem *)sender;
417{
418 [self _setPath: [sender representedObject]];
419 if (![self _validateWithPreview: YES]) {
420 [[self menu] removeItem: sender];
421 }
422}
423
424- (void)_aliasSelected:(NSMenuItem *)sender;
425{
426 BDAlias *alias = [sender representedObject];
427 int index = [self indexOfItem: sender], otherIndex = [self indexOfItem: otherItem];
428 [self _setAlias: alias];
429 if (![self _validateWithPreview: YES]) {
430 [[self menu] removeItem: sender];
431 } else if (index > otherIndex + 1) { // move "other" item to top of list
432 int recentIndex = [recentMediaAliasData count] - index + otherIndex;
433 NSMenuItem *item = [[self itemAtIndex: index] retain];
434 NSData *data = [[recentMediaAliasData objectAtIndex: recentIndex] retain];
435 // [self _validateRecentMedia];
436 [self removeItemAtIndex: index];
437 [[self menu] insertItem: item atIndex: otherIndex + 1];
438 [self selectItem: item];
439 [item release];
440 NSAssert(recentIndex >= 0, @"Recent media index invalid");
441 // NSLog(@"_aliasSelected removing item %d - %d + %d = %d of recentMediaAliasData", [recentMediaAliasData count], index, otherIndex, recentIndex);
442 [recentMediaAliasData removeObjectAtIndex: recentIndex];
443 [recentMediaAliasData addObject: data];
444 [self _validateRecentMedia];
445 [data release];
446 } // else NSLog(@"_aliasSelected ...already at top");
447}
448
449- (IBAction)select:(id)sender;
450{
451 NSOpenPanel *openPanel = [NSOpenPanel openPanel];
452 NSString *path = [selectedAlias fullPath];
453 [openPanel setAllowsMultipleSelection: NO];
454 [openPanel setCanChooseDirectories: NO];
455 [openPanel setCanChooseFiles: YES];
456 [openPanel beginSheetForDirectory: [path stringByDeletingLastPathComponent]
457 file: [path lastPathComponent]
458 types: nil // XXX fix for QuickTime!
459 modalForWindow: [self window]
460 modalDelegate: self
461 didEndSelector: @selector(openPanelDidEnd:returnCode:contextInfo:)
462 contextInfo: nil];
463}
464
465- (void)openPanelDidEnd:(NSOpenPanel *)sheet returnCode:(int)returnCode contextInfo:(void *)contextInfo;
466{
467 [sheet close];
468
469 if (returnCode == NSOKButton) {
470 NSArray *files = [sheet filenames];
471 NSAssert1([files count] == 1, @"%d items returned, only one expected", [files count]);
472 [self _setPath: [files objectAtIndex: 0]];
473 if ([self _validateWithPreview: YES]) {
474 [self selectItem: [self _itemForAlias: selectedAlias]];
475 }
476 } else {
477 // "Other..." item is still selected, revert to previously selected item
478 // XXX issue with cancelling, top item in recent menu is sometimes duplicated!?
479 [self selectItem: [self _itemForAlias: selectedAlias]];
480 }
481 // [self _validateRecentMedia];
482}
483
484- (void)setEnabled:(BOOL)flag;
485{
486 [super setEnabled: flag];
487 if (flag) ; // XXX [self startSoundPreview: self]; // need to prohibit at startup
488 else [self stopSoundPreview: self];
489}
490
491#pragma mark drag feedback
492
493- (void)drawRect:(NSRect)rect;
494{
495 if (dragAccepted) {
496 NSWindow *window = [self window];
497 NSRect boundsRect = [self bounds];
498 BOOL isFirstResponder = ([window firstResponder] == self);
499 // focus ring and drag feedback interfere with one another
500 if (isFirstResponder) [window makeFirstResponder: window];
501 [super drawRect: rect];
502 [[NSColor selectedControlColor] set];
503 NSFrameRectWithWidthUsingOperation(NSInsetRect(boundsRect, 2, 2), 3, NSCompositeSourceIn);
504 if (isFirstResponder) [window makeFirstResponder: self];
505 } else {
506 [super drawRect: rect];
507 }
508}
509
510@end
511
512@implementation NJRQTMediaPopUpButton (NSDraggingDestination)
513
514- (BOOL)acceptsDragFrom:(id <NSDraggingInfo>)sender;
515{
516 NSURL *url = [NSURL URLFromPasteboard: [sender draggingPasteboard]];
517 NSFileManager *fm = [NSFileManager defaultManager];
518 BOOL isDir;
519
520 if (url == nil || ![url isFileURL]) return NO;
521
522 if (![fm fileExistsAtPath: [url path] isDirectory: &isDir]) return NO;
523
524 if (isDir) return NO;
525
526 return YES;
527}
528
529- (NSString *)_descriptionForDraggingInfo:(id <NSDraggingInfo>)sender;
530{
531 NSDragOperation mask = [sender draggingSourceOperationMask];
532 NSMutableString *s = [NSMutableString stringWithFormat: @"Drag seq %d source: %@",
533 [sender draggingSequenceNumber], [sender draggingSource]];
534 NSPasteboard *draggingPasteboard = [sender draggingPasteboard];
535 NSArray *types = [draggingPasteboard types];
536 NSEnumerator *e = [types objectEnumerator];
537 NSString *type;
538 [s appendString: @"\nDrag operations:"];
539 if (mask & NSDragOperationCopy) [s appendString: @" copy"];
540 if (mask & NSDragOperationLink) [s appendString: @" link"];
541 if (mask & NSDragOperationGeneric) [s appendString: @" generic"];
542 if (mask & NSDragOperationPrivate) [s appendString: @" private"];
543 if (mask & NSDragOperationMove) [s appendString: @" move"];
544 if (mask & NSDragOperationDelete) [s appendString: @" delete"];
545 if (mask & NSDragOperationEvery) [s appendString: @" every"];
546 if (mask & NSDragOperationNone) [s appendString: @" none"];
547 [s appendFormat: @"\nImage: %@ at %@", [sender draggedImage],
548 NSStringFromPoint([sender draggedImageLocation])];
549 [s appendFormat: @"\nDestination: %@ at %@", [sender draggingDestinationWindow],
550 NSStringFromPoint([sender draggingLocation])];
551 [s appendFormat: @"\nPasteboard: %@ types:", draggingPasteboard];
552 while ( (type = [e nextObject]) != nil) {
553 if ([type hasPrefix: @"CorePasteboardFlavorType 0x"]) {
554 const char *osTypeHex = [[type substringFromIndex: [type rangeOfString: @"0x" options: NSBackwardsSearch].location] lossyCString];
555 OSType osType;
556 sscanf(osTypeHex, "%lx", &osType);
557 [s appendFormat: @" '%4s'", &osType];
558 } else {
559 [s appendFormat: @" '%@'", type];
560 }
561 }
562 return s;
563}
564
565- (NSDragOperation)draggingEntered:(id <NSDraggingInfo>)sender;
566{
567 if ([self acceptsDragFrom: sender] && [sender draggingSourceOperationMask] &
568 (NSDragOperationCopy | NSDragOperationLink)) {
569 dragAccepted = YES;
570 [self setNeedsDisplay: YES];
571 // NSLog(@"draggingEntered accept:\n%@", [self _descriptionForDraggingInfo: sender]);
572 return NSDragOperationLink;
573 }
574 return NSDragOperationNone;
575}
576
577- (void)draggingExited:(id <NSDraggingInfo>)sender;
578{
579 dragAccepted = NO;
580 [self setNeedsDisplay: YES];
581}
582
583- (BOOL)prepareForDragOperation:(id <NSDraggingInfo>)sender;
584{
585 dragAccepted = NO;
586 [self setNeedsDisplay: YES];
587 return [self acceptsDragFrom: sender];
588}
589
590- (BOOL)performDragOperation:(id <NSDraggingInfo>)sender;
591{
592 if ([sender draggingSource] != self) {
593 NSURL *url = [NSURL URLFromPasteboard: [sender draggingPasteboard]];
594 if (url == nil) return NO;
595 [self _setPath: [url path]];
596 if ([self _validateWithPreview: YES]) {
597 [self selectItem: [self _itemForAlias: selectedAlias]];
598 }
599 }
600 return YES;
601}
602
603@end
Note: See TracBrowser for help on using the repository browser.