Phase 4: Medien-Widget mit dreistufiger Kaskade
Adapter für alles inklusive Browser, AppleScript für Spotify und Musik, Medientasten als letzte Reserve. Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die Now-Playing-Schnittstelle in macOS 15.4 schon einmal zugemacht. Fällt der Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion. Deshalb bleiben die Transporttasten auch dann bedienbar, wenn gar keine Metadaten da sind. Der Fortschritt zählt zwischen den Ereignissen selbst hoch. Spike B hatte gezeigt, dass elapsedTime nicht laufend nachgeliefert wird: ein Scrubber, der stur den letzten Wert anzeigt, steht still, während der Titel läuft. Gerechnet wird aus asOf und playbackRate, begrenzt auf die Titellänge — sonst zeigt die Anzeige nach dem Ende weiter steigende Zahlen. Teilmeldungen (diff = true) ergänzen den Stand, statt ihn zu ersetzen. Eine Meldung mit nur der Position würde sonst Titel, Interpret und Cover löschen. In AnyDecodable wird Bool vor Double geprüft. Andernfalls wird "playing": true zu 1 und die Wiedergabe gilt für immer als angehalten. Play/Pause schaltet sofort um, statt auf das nächste Ereignis zu warten. Ein Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt an; das echte Ereignis korrigiert den Stand ohnehin. Der Adapter liegt als Quellcode unter Vendor/ (BSD-3, Stand in ONYX-VERSION.txt) und wird beim Bauen kompiliert, statt als fertige Binary eingecheckt zu werden. Er wird bewusst nicht gelinkt: geladen wird er von /usr/bin/perl, im eigenen Prozess greift Apples Entitlement-Prüfung. 124 Tests grün.
This commit is contained in:
+523
@@ -0,0 +1,523 @@
|
||||
// Copyright (c) 2025 Jonas van den Berg
|
||||
// This file is licensed under the BSD 3-Clause License.
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <CoreFoundation/CoreFoundation.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#import "MediaRemoteAdapter.h"
|
||||
#import "adapter/env.h"
|
||||
#import "adapter/globals.h"
|
||||
#import "adapter/keys.h"
|
||||
#import "adapter/now_playing.h"
|
||||
#import "private/MediaRemote.h"
|
||||
#import "utility/Debounce.h"
|
||||
#import "utility/helpers.h"
|
||||
|
||||
#ifndef DEBOUNCE_DELAY_MILLIS
|
||||
#define DEBOUNCE_DELAY_MILLIS 0
|
||||
#endif
|
||||
|
||||
static CFRunLoopRef g_runLoop = NULL;
|
||||
|
||||
static NSString *serializeData(NSDictionary *data, BOOL diff, BOOL pretty) {
|
||||
return serializeJsonDictionarySafe(
|
||||
@{
|
||||
@"type" : @"data",
|
||||
@"diff" : @(diff),
|
||||
@"payload" : data ?: @{},
|
||||
},
|
||||
pretty);
|
||||
}
|
||||
|
||||
static NSDictionary *createDiff(NSDictionary *a, NSDictionary *b) {
|
||||
NSMutableDictionary *diff = [NSMutableDictionary dictionary];
|
||||
NSMutableSet *allKeys = [NSMutableSet setWithArray:a.allKeys];
|
||||
[allKeys addObjectsFromArray:b.allKeys];
|
||||
for (id key in allKeys) {
|
||||
id oldValue = a[key];
|
||||
id newValue = b[key];
|
||||
BOOL valuesDiffer = NO;
|
||||
if (oldValue == nil && newValue != nil) {
|
||||
valuesDiffer = YES;
|
||||
} else if (oldValue != nil && newValue == nil) {
|
||||
valuesDiffer = YES;
|
||||
} else if (![oldValue isEqual:newValue]) {
|
||||
valuesDiffer = YES;
|
||||
}
|
||||
if (valuesDiffer) {
|
||||
diff[key] = newValue ?: [NSNull null];
|
||||
}
|
||||
}
|
||||
return [diff copy];
|
||||
}
|
||||
|
||||
static BOOL isSameItemIdentity(NSDictionary *a, NSDictionary *b) {
|
||||
NSArray<NSString *> *keys = identifyingPayloadKeys();
|
||||
for (NSString *key in keys) {
|
||||
id aValue = a[key];
|
||||
id bValue = b[key];
|
||||
if (aValue == nil && bValue == nil) {
|
||||
continue;
|
||||
}
|
||||
if (aValue == nil || bValue == nil) {
|
||||
return NO;
|
||||
}
|
||||
if (![aValue isEqual:bValue]) {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static NSDictionary *previousData = nil;
|
||||
|
||||
static void printData(NSDictionary *data, BOOL diff, BOOL pretty) {
|
||||
NSString *serialized = nil;
|
||||
if (diff && previousData != nil && isSameItemIdentity(previousData, data)) {
|
||||
NSDictionary *result = createDiff(previousData, data);
|
||||
if ([result count] == 0) {
|
||||
return;
|
||||
}
|
||||
serialized = serializeData(result, YES, pretty);
|
||||
} else {
|
||||
serialized = serializeData(data, NO, pretty);
|
||||
}
|
||||
if (serialized != nil) {
|
||||
if (diff) {
|
||||
previousData = [data copy];
|
||||
}
|
||||
// Print the serialized data without duplicates. Note that while this
|
||||
// can fail when the key order in the serialized JSON output changes,
|
||||
// it practically won't because if it did, there would also be a change
|
||||
// in values that needs to be reported.
|
||||
printOutUnique(serialized);
|
||||
}
|
||||
if (!diff) {
|
||||
previousData = nil;
|
||||
}
|
||||
}
|
||||
|
||||
static void appForNotification(NSNotification *notification,
|
||||
void (^block)(NSRunningApplication *)) {
|
||||
NSDictionary *userInfo = notification.userInfo;
|
||||
id pidValue = userInfo[kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey];
|
||||
if (pidValue != nil) {
|
||||
int pid = [pidValue intValue];
|
||||
appForPID(pid, block);
|
||||
} else {
|
||||
block(nil);
|
||||
}
|
||||
};
|
||||
|
||||
typedef struct MetadataStats {
|
||||
BOOL trackTitleChanged;
|
||||
int identifyingTrackKeysIdentical;
|
||||
int identifyingTrackKeysChanged;
|
||||
} MetadataStats;
|
||||
|
||||
static MetadataStats createMetadataStats() {
|
||||
MetadataStats stats = {
|
||||
.trackTitleChanged = NO,
|
||||
.identifyingTrackKeysIdentical = 0,
|
||||
.identifyingTrackKeysChanged = 0,
|
||||
};
|
||||
return stats;
|
||||
}
|
||||
|
||||
static MetadataStats compareIdentifyingTrackKeys(NSDictionary *prev,
|
||||
NSDictionary *next) {
|
||||
MetadataStats stats = createMetadataStats();
|
||||
for (NSString *key in @[ kMRATitle, kMRAArtist, kMRAAlbum ]) {
|
||||
id a = prev[key], b = next[key];
|
||||
if (a == nil || b == nil)
|
||||
continue;
|
||||
if ([a isEqual:b]) {
|
||||
stats.identifyingTrackKeysIdentical++;
|
||||
} else {
|
||||
stats.identifyingTrackKeysChanged++;
|
||||
if ([key isEqualToString:kMRATitle]) {
|
||||
stats.trackTitleChanged = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
extern void adapter_stream() {
|
||||
|
||||
// Get ADAPTER_TEST_MODE as a boolean and set BOOL isTestMode
|
||||
BOOL isTestMode = NO;
|
||||
char *testModeEnv = getenv("ADAPTER_TEST_MODE");
|
||||
if (testModeEnv && strcmp(testModeEnv, "0") != 0 &&
|
||||
strlen(testModeEnv) > 0) {
|
||||
isTestMode = YES;
|
||||
}
|
||||
|
||||
int debounce_delay_millis = 0;
|
||||
NSNumber *debounce_option = getEnvOptionInt(@"debounce");
|
||||
if (debounce_option != nil) {
|
||||
debounce_delay_millis = [debounce_option intValue];
|
||||
}
|
||||
|
||||
NSString *no_diff_option = getEnvOption(@"no_diff");
|
||||
NSString *no_artwork_option = getEnvOption(@"no-artwork");
|
||||
NSString *micros_option = getEnvOption(@"micros");
|
||||
NSString *human_readable_option = getEnvOption(@"human-readable");
|
||||
|
||||
// This option is needed for media players which, when changing tracks,
|
||||
// update the artist and/or other fields later than e.g. the title, the
|
||||
// invalid in-between metadata therefore representing "peculiar" media. The
|
||||
// only known player that does this is the TIDAL desktop player with the
|
||||
// bundle ID "com.tidal.desktop". This is easy to reproduce when playing
|
||||
// media from a playlist with tracks from different artists.
|
||||
// FIXME Implement this for any bundle ID, should other players need it.
|
||||
// In that case parse any "experimental-peculiar-debounce:*" option.
|
||||
NSNumber *peculiar_debounce_option =
|
||||
getEnvOptionInt(@"experimental-peculiar-debounce:com.tidal.desktop");
|
||||
__block NSString *peculiar_bundle_id = nil;
|
||||
__block Debounce *peculiar_debounce = nil;
|
||||
__block BOOL did_peculiar_debounce = NO;
|
||||
if (peculiar_debounce_option != nil) {
|
||||
peculiar_bundle_id = @"com.tidal.desktop";
|
||||
int debounce_millis = [peculiar_debounce_option intValue];
|
||||
peculiar_debounce =
|
||||
[[Debounce alloc] initWithDelay:(debounce_millis / 1000.0)
|
||||
queue:g_serialdispatchQueue];
|
||||
}
|
||||
|
||||
__block NSMutableDictionary *liveData = [NSMutableDictionary dictionary];
|
||||
__block MetadataStats liveDataStats = createMetadataStats();
|
||||
__block const Debounce *const debounce =
|
||||
[[Debounce alloc] initWithDelay:(debounce_delay_millis / 1000.0)
|
||||
queue:g_serialdispatchQueue];
|
||||
__block const BOOL no_diff = (no_diff_option != nil);
|
||||
__block const BOOL no_artwork = (no_artwork_option != nil);
|
||||
__block const BOOL convert_micros = (micros_option != nil);
|
||||
__block const bool human_readable = (human_readable_option != nil);
|
||||
|
||||
void (^localPrintData)(NSDictionary *) = ^(NSDictionary *data) {
|
||||
printData(data, !no_diff, human_readable);
|
||||
};
|
||||
|
||||
void (^directHandle)() = ^() {
|
||||
if (allMandatoryPayloadKeysSet(liveData)) {
|
||||
if (human_readable) {
|
||||
NSMutableDictionary *shallowClone =
|
||||
[NSMutableDictionary dictionaryWithDictionary:liveData];
|
||||
makePayloadHumanReadable(shallowClone);
|
||||
localPrintData(shallowClone);
|
||||
} else {
|
||||
localPrintData(liveData);
|
||||
}
|
||||
} else {
|
||||
localPrintData(nil);
|
||||
}
|
||||
};
|
||||
|
||||
void (^internalHandle)(bool) = ^(bool updatedStats) {
|
||||
if (peculiar_debounce == nil ||
|
||||
![peculiar_bundle_id isEqual:liveData[kMRABundleIdentifier]]) {
|
||||
directHandle();
|
||||
return;
|
||||
}
|
||||
if (updatedStats && liveDataStats.trackTitleChanged &&
|
||||
liveDataStats.identifyingTrackKeysIdentical > 0) {
|
||||
did_peculiar_debounce = true;
|
||||
[peculiar_debounce call:^{
|
||||
did_peculiar_debounce = false;
|
||||
directHandle();
|
||||
}];
|
||||
} else if (did_peculiar_debounce &&
|
||||
(!updatedStats ||
|
||||
liveDataStats.identifyingTrackKeysChanged == 0)) {
|
||||
// Ignore this handle call, since there is an active debounce call.
|
||||
} else {
|
||||
[peculiar_debounce cancel];
|
||||
did_peculiar_debounce = false;
|
||||
directHandle();
|
||||
}
|
||||
};
|
||||
|
||||
void (^handle)() = ^() {
|
||||
internalHandle(false);
|
||||
};
|
||||
|
||||
void (^handleWithUpdatedStats)() = ^() {
|
||||
internalHandle(true);
|
||||
};
|
||||
|
||||
void (^requestNowPlayingApplicationPID)() = ^{
|
||||
g_mediaRemote.getNowPlayingApplicationPID(
|
||||
g_serialdispatchQueue, ^(int pid) {
|
||||
if (pid == 0) {
|
||||
liveData[kMRAProcessIdentifier] = nil;
|
||||
handle();
|
||||
return;
|
||||
}
|
||||
liveData[kMRAProcessIdentifier] = @(pid);
|
||||
bool ok = appForPID(pid, ^(NSRunningApplication *process) {
|
||||
if (process.bundleIdentifier != nil) {
|
||||
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
|
||||
}
|
||||
handle();
|
||||
});
|
||||
if (!ok) {
|
||||
handle();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void (^requestNowPlayingParentApplicationBundleIdentifier)() = ^{
|
||||
g_mediaRemote.getNowPlayingClient(g_serialdispatchQueue, ^(id client) {
|
||||
NSString *parentAppBundleID = nil;
|
||||
if (client && [client respondsToSelector:@selector
|
||||
(parentApplicationBundleIdentifier)]) {
|
||||
id result = [client
|
||||
performSelector:@selector(parentApplicationBundleIdentifier)];
|
||||
if ([result isKindOfClass:[NSString class]]) {
|
||||
parentAppBundleID = result;
|
||||
}
|
||||
}
|
||||
if (parentAppBundleID) {
|
||||
liveData[kMRAParentApplicationBundleIdentifier] = parentAppBundleID;
|
||||
} else {
|
||||
[liveData removeObjectForKey:kMRAParentApplicationBundleIdentifier];
|
||||
}
|
||||
handle();
|
||||
});
|
||||
};
|
||||
|
||||
void (^requestNowPlayingApplicationIsPlaying)() = ^{
|
||||
g_mediaRemote.getNowPlayingApplicationIsPlaying(
|
||||
g_serialdispatchQueue, ^(bool isPlaying) {
|
||||
liveData[kMRAPlaying] = @(isPlaying);
|
||||
handle();
|
||||
});
|
||||
};
|
||||
|
||||
void (^requestNowPlayingInfo)() = ^{
|
||||
g_mediaRemote.getNowPlayingInfo(g_serialdispatchQueue, ^(
|
||||
NSDictionary *information) {
|
||||
NSString *serviceIdentifier =
|
||||
information[kMRMediaRemoteNowPlayingInfoServiceIdentifier];
|
||||
if (!isTestMode &&
|
||||
[serviceIdentifier
|
||||
isEqualToString:
|
||||
@"com.vandenbe.MediaRemoteAdapter.TestClient"]) {
|
||||
return;
|
||||
}
|
||||
NSMutableDictionary *converted = convertNowPlayingInformation(
|
||||
information, convert_micros, false, no_artwork);
|
||||
// Transfer anything over from the existing live data.
|
||||
if (liveData[kMRAProcessIdentifier] != nil) {
|
||||
converted[kMRAProcessIdentifier] = liveData[kMRAProcessIdentifier];
|
||||
}
|
||||
if (liveData[kMRABundleIdentifier] != nil) {
|
||||
converted[kMRABundleIdentifier] = liveData[kMRABundleIdentifier];
|
||||
}
|
||||
if (liveData[kMRAParentApplicationBundleIdentifier] != nil) {
|
||||
converted[kMRAParentApplicationBundleIdentifier] =
|
||||
liveData[kMRAParentApplicationBundleIdentifier];
|
||||
}
|
||||
if (liveData[kMRAPlaying] != nil) {
|
||||
converted[kMRAPlaying] = liveData[kMRAPlaying];
|
||||
}
|
||||
// Use the old artwork data, since often the MediaRemote framework
|
||||
// unloads the artwork and then loads it again shortly after.
|
||||
// Only do this when the items have the same identity.
|
||||
if (isSameItemIdentity(liveData, converted) &&
|
||||
liveData[kMRAArtworkData] != nil &&
|
||||
liveData[kMRAArtworkData] != [NSNull null] &&
|
||||
converted[kMRAArtworkData] == nil) {
|
||||
converted[kMRAArtworkData] = liveData[kMRAArtworkData];
|
||||
if (liveData[kMRAArtworkMimeType] != nil &&
|
||||
liveData[kMRAArtworkMimeType] != [NSNull null] &&
|
||||
converted[kMRAArtworkMimeType] == nil) {
|
||||
converted[kMRAArtworkMimeType] = liveData[kMRAArtworkMimeType];
|
||||
}
|
||||
}
|
||||
|
||||
liveDataStats = compareIdentifyingTrackKeys(liveData, converted);
|
||||
[liveData setDictionary:converted];
|
||||
handleWithUpdatedStats();
|
||||
});
|
||||
};
|
||||
|
||||
void (^requestAll)() = ^{
|
||||
requestNowPlayingApplicationPID();
|
||||
requestNowPlayingParentApplicationBundleIdentifier();
|
||||
requestNowPlayingApplicationIsPlaying();
|
||||
requestNowPlayingInfo();
|
||||
};
|
||||
|
||||
void (^resetAll)() = ^{
|
||||
[liveData removeAllObjects];
|
||||
};
|
||||
|
||||
void (^refreshAll)() = ^{
|
||||
resetAll();
|
||||
requestAll();
|
||||
};
|
||||
|
||||
// FIXME Is this foolproof? This continues and registers observers
|
||||
// which might intervene with the initial requests.
|
||||
requestAll();
|
||||
|
||||
NSNotificationCenter *default_center = [NSNotificationCenter defaultCenter];
|
||||
NSNotificationCenter *shared_workscape_notification_center =
|
||||
[[NSWorkspace sharedWorkspace] notificationCenter];
|
||||
|
||||
// TODO Refactor the below two callbacks. They share a lot of code.
|
||||
|
||||
id is_playing_change_observer = [default_center
|
||||
addObserverForName:
|
||||
kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification
|
||||
object:nil
|
||||
queue:nil
|
||||
usingBlock:^(NSNotification *notification) {
|
||||
dispatch_async(g_serialdispatchQueue, ^() {
|
||||
appForNotification(notification, ^(
|
||||
NSRunningApplication *process) {
|
||||
if (process == nil) {
|
||||
// The process for this notification could not be
|
||||
// determined. Assume that there is no now playing
|
||||
// application anymore.
|
||||
resetAll();
|
||||
handle();
|
||||
return;
|
||||
}
|
||||
id isPlayingValue =
|
||||
notification.userInfo
|
||||
[kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey];
|
||||
if (isPlayingValue == nil) {
|
||||
return;
|
||||
}
|
||||
if (liveData[kMRABundleIdentifier] != nil &&
|
||||
process.bundleIdentifier != nil &&
|
||||
![liveData[kMRABundleIdentifier]
|
||||
isEqual:process.bundleIdentifier]) {
|
||||
// This is a different process, reset all data.
|
||||
resetAll();
|
||||
}
|
||||
if (liveData[kMRAProcessIdentifier] != nil &&
|
||||
![liveData[kMRAProcessIdentifier]
|
||||
isEqual:@(process.processIdentifier)]) {
|
||||
// This is a different process, reset all data.
|
||||
resetAll();
|
||||
}
|
||||
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
|
||||
requestNowPlayingParentApplicationBundleIdentifier();
|
||||
liveData[kMRAPlaying] = @([isPlayingValue boolValue]);
|
||||
if (liveData[kMRATitle] == nil) {
|
||||
requestNowPlayingInfo();
|
||||
} else {
|
||||
handle();
|
||||
}
|
||||
});
|
||||
});
|
||||
}];
|
||||
|
||||
id info_change_observer = [default_center
|
||||
addObserverForName:kMRMediaRemoteNowPlayingInfoDidChangeNotification
|
||||
object:nil
|
||||
queue:nil
|
||||
usingBlock:^(NSNotification *notification) {
|
||||
[debounce call:^{
|
||||
appForNotification(notification, ^(
|
||||
NSRunningApplication *process) {
|
||||
if (process == nil) {
|
||||
// The process for this notification could not be
|
||||
// determined. Assume that there is no now playing
|
||||
// application anymore.
|
||||
resetAll();
|
||||
handle();
|
||||
return;
|
||||
}
|
||||
if (liveData[kMRABundleIdentifier] != nil &&
|
||||
process.bundleIdentifier != nil &&
|
||||
![liveData[kMRABundleIdentifier]
|
||||
isEqual:process.bundleIdentifier]) {
|
||||
// This is a different process, reset all data.
|
||||
resetAll();
|
||||
}
|
||||
if (liveData[kMRAProcessIdentifier] != nil &&
|
||||
![liveData[kMRAProcessIdentifier]
|
||||
isEqual:@(process.processIdentifier)]) {
|
||||
// This is a different process, reset all data.
|
||||
resetAll();
|
||||
}
|
||||
if (liveData[kMRAProcessIdentifier] == nil) {
|
||||
requestNowPlayingApplicationPID();
|
||||
}
|
||||
if (liveData[kMRAParentApplicationBundleIdentifier] ==
|
||||
nil) {
|
||||
requestNowPlayingParentApplicationBundleIdentifier();
|
||||
}
|
||||
if (liveData[kMRAPlaying] == nil) {
|
||||
requestNowPlayingApplicationIsPlaying();
|
||||
}
|
||||
requestNowPlayingInfo();
|
||||
});
|
||||
}];
|
||||
}];
|
||||
|
||||
// Register notifications for when applications are closed.
|
||||
id app_termination_observer = [shared_workscape_notification_center
|
||||
addObserverForName:NSWorkspaceDidTerminateApplicationNotification
|
||||
object:nil
|
||||
queue:nil
|
||||
usingBlock:^(NSNotification *notification) {
|
||||
dispatch_async(g_serialdispatchQueue, ^() {
|
||||
NSDictionary *userInfo = [notification userInfo];
|
||||
id bundleIdentifier =
|
||||
userInfo[@"NSApplicationBundleIdentifier"];
|
||||
if (bundleIdentifier != nil &&
|
||||
[bundleIdentifier
|
||||
isEqual:liveData[kMRABundleIdentifier]]) {
|
||||
// Refresh all data, since the application terminated.
|
||||
refreshAll();
|
||||
}
|
||||
});
|
||||
}];
|
||||
|
||||
g_mediaRemote.registerForNowPlayingNotifications(g_serialdispatchQueue);
|
||||
|
||||
CFRunLoopRun();
|
||||
|
||||
g_mediaRemote.unregisterForNowPlayingNotifications();
|
||||
|
||||
[default_center removeObserver:is_playing_change_observer];
|
||||
[default_center removeObserver:info_change_observer];
|
||||
[shared_workscape_notification_center
|
||||
removeObserver:app_termination_observer];
|
||||
}
|
||||
|
||||
extern void adapter_stream_env() { adapter_stream(); }
|
||||
|
||||
extern void _adapter_stream_cancel() {
|
||||
if (g_runLoop) {
|
||||
CFRunLoopStop(g_runLoop);
|
||||
}
|
||||
}
|
||||
|
||||
static void handleSignal(int signal) {
|
||||
if (signal == SIGINT || signal == SIGTERM) {
|
||||
_adapter_stream_cancel();
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void init() {
|
||||
g_runLoop = CFRunLoopGetCurrent();
|
||||
signal(SIGINT, handleSignal);
|
||||
signal(SIGTERM, handleSignal);
|
||||
}
|
||||
__attribute__((destructor)) static void teardown() { _adapter_stream_cancel(); }
|
||||
|
||||
// FIXME Fix "peculiar media" (artist is updated later than title). Example:
|
||||
/*
|
||||
35.558 Thirteen by Big Star on Camping Songs
|
||||
36.091 Good Vibrations (Remastered 2001) by Big Star on Camping Songs
|
||||
36.204 Good Vibrations (Remastered 2001) by Big Star on Camping Songs (+image)
|
||||
36.624 Good Vibrations (Remastered 2001) by The Beach Boys on Camping Songs
|
||||
*/
|
||||
Reference in New Issue
Block a user