@leg3ndy/otto-bridge 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -221,6 +221,136 @@ function descriptionWantsPause(description) {
221
221
  function descriptionWantsResume(description) {
222
222
  return /\b(retoma|retomar|resume|continu[ae]r|despausa|play)\b/.test(normalizeText(description || ""));
223
223
  }
224
+ function descriptionWantsSpotifyTrackPageOpen(description) {
225
+ const normalized = normalizeText(description || "");
226
+ return /\bspotify\b/.test(normalized)
227
+ && /\b(pagina|titulo|nome|link)\b/.test(normalized)
228
+ && /\b(faixa|musica|track)\b/.test(normalized);
229
+ }
230
+ function descriptionWantsSpotifyTrackPagePlay(description) {
231
+ const normalized = normalizeText(description || "");
232
+ return /\bspotify\b/.test(normalized)
233
+ && /\b(play|reproduzir|tocar|verde|principal)\b/.test(normalized)
234
+ && /\b(pagina|faixa|musica|track)\b/.test(normalized);
235
+ }
236
+ function isSpotifySafariDomOnlyStep(description) {
237
+ return descriptionWantsSpotifyTrackPageOpen(description) || descriptionWantsSpotifyTrackPagePlay(description);
238
+ }
239
+ function playerStateLooksActive(playerState) {
240
+ const normalized = normalizeText(playerState || "");
241
+ return normalized.includes("pause") || normalized.includes("pausar");
242
+ }
243
+ function browserStateMatchesSpotifyTrackDescription(state, description) {
244
+ if (!state) {
245
+ return false;
246
+ }
247
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
248
+ if (!normalizedUrl.includes("open.spotify.com/track/")) {
249
+ return false;
250
+ }
251
+ const haystack = [
252
+ state.playerTitle || "",
253
+ state.title || "",
254
+ String(state.text || "").slice(0, 4000),
255
+ ].join(" ");
256
+ const normalizedHaystack = normalizeText(haystack);
257
+ if (!normalizedHaystack) {
258
+ return false;
259
+ }
260
+ const quotedPhrases = extractQuotedPhrases(description);
261
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
262
+ return true;
263
+ }
264
+ const mediaQueryTokens = extractMediaQueryTokens(description);
265
+ if (!mediaQueryTokens.length) {
266
+ return false;
267
+ }
268
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
269
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
270
+ }
271
+ function spotifyStateContentMatchesDescription(state, description) {
272
+ if (!state) {
273
+ return false;
274
+ }
275
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
276
+ if (!normalizedUrl.includes("open.spotify.com")) {
277
+ return false;
278
+ }
279
+ const haystack = [
280
+ state.playerTitle || "",
281
+ state.title || "",
282
+ String(state.text || "").slice(0, 4000),
283
+ ].join(" ");
284
+ const normalizedHaystack = normalizeText(haystack);
285
+ if (!normalizedHaystack) {
286
+ return false;
287
+ }
288
+ const quotedPhrases = extractQuotedPhrases(description);
289
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
290
+ return true;
291
+ }
292
+ const mediaQueryTokens = extractMediaQueryTokens(description);
293
+ if (!mediaQueryTokens.length) {
294
+ return false;
295
+ }
296
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
297
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
298
+ }
299
+ function spotifyPlayerAlreadyPlayingMatchesDescription(state, description) {
300
+ if (!state) {
301
+ return false;
302
+ }
303
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
304
+ if (!normalizedUrl.includes("open.spotify.com")) {
305
+ return false;
306
+ }
307
+ if (!playerStateLooksActive(state.playerState || "")) {
308
+ return false;
309
+ }
310
+ const haystack = [
311
+ state.playerTitle || "",
312
+ state.title || "",
313
+ String(state.text || "").slice(0, 4000),
314
+ ].join(" ");
315
+ const normalizedHaystack = normalizeText(haystack);
316
+ if (!normalizedHaystack) {
317
+ return false;
318
+ }
319
+ const quotedPhrases = extractQuotedPhrases(description);
320
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
321
+ return true;
322
+ }
323
+ const mediaQueryTokens = extractMediaQueryTokens(description);
324
+ if (!mediaQueryTokens.length) {
325
+ return false;
326
+ }
327
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
328
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
329
+ }
330
+ function spotifyTrackAlreadyPlayingMatchesDescription(state, description) {
331
+ return browserStateMatchesSpotifyTrackDescription(state, description)
332
+ && spotifyPlayerAlreadyPlayingMatchesDescription(state, description);
333
+ }
334
+ function spotifyTrackPageLooksPlaying(state, description) {
335
+ return browserStateMatchesSpotifyTrackDescription(state, description)
336
+ && playerStateLooksActive(state?.playerState || "");
337
+ }
338
+ function spotifyDescriptionsLikelyMatch(left, right) {
339
+ const leftPhrases = new Set(extractQuotedPhrases(left));
340
+ const rightPhrases = new Set(extractQuotedPhrases(right));
341
+ for (const phrase of leftPhrases) {
342
+ if (rightPhrases.has(phrase)) {
343
+ return true;
344
+ }
345
+ }
346
+ const leftTokens = extractMediaQueryTokens(left);
347
+ const rightTokens = new Set(extractMediaQueryTokens(right));
348
+ if (!leftTokens.length || !rightTokens.size) {
349
+ return false;
350
+ }
351
+ const overlap = leftTokens.filter((token) => rightTokens.has(token)).length;
352
+ return overlap >= Math.max(1, Math.ceil(Math.min(leftTokens.length, rightTokens.size) * 0.5));
353
+ }
224
354
  function extractNativeMediaTransportCommand(description) {
225
355
  const normalizedDescription = normalizeText(description || "");
226
356
  if (!normalizedDescription
@@ -473,6 +603,10 @@ function urlHostname(url) {
473
603
  return null;
474
604
  }
475
605
  }
606
+ function isSpotifySearchUrl(url) {
607
+ const normalized = normalizeComparableUrl(normalizeUrl(url));
608
+ return normalized.includes("open.spotify.com/search/");
609
+ }
476
610
  function uniqueStrings(values) {
477
611
  const seen = new Set();
478
612
  const result = [];
@@ -971,6 +1105,9 @@ export class NativeMacOSJobExecutor {
971
1105
  lastActiveApp = null;
972
1106
  lastVisualTargetDescription = null;
973
1107
  lastVisualTargetApp = null;
1108
+ lastSatisfiedSpotifyDescription = null;
1109
+ lastSatisfiedSpotifyConfirmedPlaying = false;
1110
+ lastSatisfiedSpotifyAt = 0;
974
1111
  whatsappBackgroundBrowser = null;
975
1112
  whatsappRuntimeMonitor = null;
976
1113
  constructor(bridgeConfig) {
@@ -987,6 +1124,9 @@ export class NativeMacOSJobExecutor {
987
1124
  if (actions.length === 0) {
988
1125
  throw new Error("Otto Bridge native-macos could not derive a supported local action from this request");
989
1126
  }
1127
+ this.lastSatisfiedSpotifyDescription = null;
1128
+ this.lastSatisfiedSpotifyConfirmedPlaying = false;
1129
+ this.lastSatisfiedSpotifyAt = 0;
990
1130
  await reporter.accepted();
991
1131
  const confirmation = extractConfirmationOptions(job, actions);
992
1132
  if (confirmation.required) {
@@ -1025,7 +1165,7 @@ export class NativeMacOSJobExecutor {
1025
1165
  }
1026
1166
  if (action.type === "press_shortcut") {
1027
1167
  await reporter.progress(progressPercent, `Enviando atalho ${action.shortcut}`);
1028
- await this.pressShortcut(action.shortcut);
1168
+ const shortcutResult = await this.pressShortcut(action.shortcut);
1029
1169
  if (action.shortcut.startsWith("media_")) {
1030
1170
  const mediaSummaryMap = {
1031
1171
  media_next: "Acionei o comando de próxima mídia no macOS.",
@@ -1035,7 +1175,14 @@ export class NativeMacOSJobExecutor {
1035
1175
  media_play: "Acionei o comando de reproduzir mídia no macOS.",
1036
1176
  media_play_pause: "Acionei o comando de play/pause de mídia no macOS.",
1037
1177
  };
1038
- completionNotes.push(mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`);
1178
+ const mediaSkippedSummaryMap = {
1179
+ media_pause: "Nenhuma mídia estava tocando no macOS, então o pause global foi pulado.",
1180
+ media_resume: "Já havia mídia tocando no macOS, então o comando de retomar foi pulado.",
1181
+ media_play: "Já havia mídia tocando no macOS, então o comando de reproduzir foi pulado.",
1182
+ };
1183
+ completionNotes.push(shortcutResult.performed
1184
+ ? (mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`)
1185
+ : (mediaSkippedSummaryMap[action.shortcut] || shortcutResult.reason || `O atalho ${action.shortcut} foi pulado no macOS.`));
1039
1186
  }
1040
1187
  continue;
1041
1188
  }
@@ -1239,7 +1386,9 @@ export class NativeMacOSJobExecutor {
1239
1386
  await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do clique`);
1240
1387
  await this.focusApp(action.app);
1241
1388
  }
1242
- const targetDescriptions = uniqueStrings([action.description, ...(action.retry_descriptions || [])]);
1389
+ const targetDescriptions = isSpotifySafariDomOnlyStep(action.description)
1390
+ ? [action.description]
1391
+ : uniqueStrings([action.description, ...(action.retry_descriptions || [])]);
1243
1392
  let clickSucceeded = false;
1244
1393
  let lastFailureReason = "";
1245
1394
  for (let attempt = 0; attempt < targetDescriptions.length; attempt += 1) {
@@ -1247,6 +1396,33 @@ export class NativeMacOSJobExecutor {
1247
1396
  const initialBrowserState = browserApp
1248
1397
  ? await this.captureBrowserPageState(browserApp).catch(() => null)
1249
1398
  : null;
1399
+ const isSpotifySafariStep = browserApp === "Safari" && isSpotifySafariDomOnlyStep(targetDescription);
1400
+ const verificationPrompt = isSpotifySafariStep ? undefined : action.verification_prompt;
1401
+ if (isSpotifySafariStep) {
1402
+ await reporter.progress(progressPercent, `Tentando concluir ${targetDescription} pelo DOM do Spotify no Safari`);
1403
+ const spotifyDomResult = await this.executeSpotifySafariDomStep(targetDescription, initialBrowserState);
1404
+ if (spotifyDomResult.ok) {
1405
+ this.rememberSatisfiedSpotifyStep(targetDescription, !!spotifyDomResult.confirmedPlaying);
1406
+ this.lastVisualTargetDescription = targetDescription;
1407
+ this.lastVisualTargetApp = browserApp || action.app || this.lastActiveApp;
1408
+ resultPayload.last_click = {
1409
+ strategy: spotifyDomResult.skipped
1410
+ ? "spotify_browser_state_skip"
1411
+ : (spotifyDomResult.click?.strategy || "spotify_safari_dom"),
1412
+ matched_text: spotifyDomResult.click?.matchedText || targetDescription,
1413
+ matched_href: spotifyDomResult.click?.matchedHref || spotifyDomResult.afterState?.url || null,
1414
+ score: spotifyDomResult.click?.score || null,
1415
+ total_candidates: spotifyDomResult.click?.totalCandidates || null,
1416
+ };
1417
+ completionNotes.push(spotifyDomResult.skipped
1418
+ ? `O Spotify Web ja estava no estado certo para ${targetDescription}, então pulei tentativas extras.`
1419
+ : `Concluí ${targetDescription} diretamente pelo DOM do Spotify Web no Safari.`);
1420
+ clickSucceeded = true;
1421
+ break;
1422
+ }
1423
+ lastFailureReason = spotifyDomResult.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`;
1424
+ continue;
1425
+ }
1250
1426
  const nativeMediaTransport = extractNativeMediaTransportCommand(targetDescription);
1251
1427
  if (nativeMediaTransport) {
1252
1428
  await reporter.progress(progressPercent, `Tentando controle de mídia nativo do macOS para ${targetDescription}`);
@@ -1254,8 +1430,13 @@ export class NativeMacOSJobExecutor {
1254
1430
  await this.triggerMacOSMediaTransport(nativeMediaTransport);
1255
1431
  let validated = false;
1256
1432
  let validationReason = "";
1257
- if (action.verification_prompt) {
1258
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "native_media_transport_result");
1433
+ if (browserApp) {
1434
+ const browserValidation = await this.confirmBrowserClick(browserApp, initialBrowserState, targetDescription, null);
1435
+ validated = browserValidation.ok;
1436
+ validationReason = browserValidation.reason;
1437
+ }
1438
+ if (!validated && verificationPrompt) {
1439
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "native_media_transport_result");
1259
1440
  if (verification.unavailable) {
1260
1441
  lastFailureReason = verification.reason;
1261
1442
  break;
@@ -1263,12 +1444,7 @@ export class NativeMacOSJobExecutor {
1263
1444
  validated = verification.ok;
1264
1445
  validationReason = verification.reason;
1265
1446
  }
1266
- else if (browserApp) {
1267
- const browserValidation = await this.confirmBrowserClick(browserApp, initialBrowserState, targetDescription, null);
1268
- validated = browserValidation.ok;
1269
- validationReason = browserValidation.reason;
1270
- }
1271
- else {
1447
+ else if (!browserApp) {
1272
1448
  validated = true;
1273
1449
  }
1274
1450
  if (validated) {
@@ -1293,8 +1469,13 @@ export class NativeMacOSJobExecutor {
1293
1469
  if (domClick?.clicked) {
1294
1470
  let validated = false;
1295
1471
  let validationReason = "";
1296
- if (action.verification_prompt) {
1297
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "dom_click_result");
1472
+ if (browserApp) {
1473
+ const browserValidation = await this.confirmBrowserClick(browserApp, initialBrowserState, targetDescription, domClick.matchedHref || null);
1474
+ validated = browserValidation.ok;
1475
+ validationReason = browserValidation.reason;
1476
+ }
1477
+ if (!validated && verificationPrompt) {
1478
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "dom_click_result");
1298
1479
  if (verification.unavailable) {
1299
1480
  lastFailureReason = verification.reason;
1300
1481
  break;
@@ -1302,10 +1483,8 @@ export class NativeMacOSJobExecutor {
1302
1483
  validated = verification.ok;
1303
1484
  validationReason = verification.reason;
1304
1485
  }
1305
- else {
1306
- const browserValidation = await this.confirmBrowserClick(browserApp, initialBrowserState, targetDescription, domClick.matchedHref || null);
1307
- validated = browserValidation.ok;
1308
- validationReason = browserValidation.reason;
1486
+ else if (!browserApp) {
1487
+ validated = true;
1309
1488
  }
1310
1489
  if (validated) {
1311
1490
  this.lastVisualTargetDescription = targetDescription;
@@ -1336,8 +1515,13 @@ export class NativeMacOSJobExecutor {
1336
1515
  if (ocrClick.clicked) {
1337
1516
  let validated = false;
1338
1517
  let validationReason = "";
1339
- if (action.verification_prompt) {
1340
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
1518
+ if (browserApp) {
1519
+ const browserValidation = await this.confirmBrowserClick(browserApp, visualBeforeState, targetDescription, null);
1520
+ validated = browserValidation.ok;
1521
+ validationReason = browserValidation.reason;
1522
+ }
1523
+ if (!validated && verificationPrompt) {
1524
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
1341
1525
  if (verification.unavailable) {
1342
1526
  lastFailureReason = verification.reason;
1343
1527
  break;
@@ -1345,12 +1529,7 @@ export class NativeMacOSJobExecutor {
1345
1529
  validated = verification.ok;
1346
1530
  validationReason = verification.reason;
1347
1531
  }
1348
- else if (browserApp) {
1349
- const browserValidation = await this.confirmBrowserClick(browserApp, visualBeforeState, targetDescription, null);
1350
- validated = browserValidation.ok;
1351
- validationReason = browserValidation.reason;
1352
- }
1353
- else {
1532
+ else if (!browserApp) {
1354
1533
  validated = true;
1355
1534
  }
1356
1535
  if (validated) {
@@ -1422,8 +1601,8 @@ export class NativeMacOSJobExecutor {
1422
1601
  y: scaledY,
1423
1602
  strategy: "visual_locator",
1424
1603
  };
1425
- if (action.verification_prompt) {
1426
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "visual_click_result");
1604
+ if (verificationPrompt) {
1605
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "visual_click_result");
1427
1606
  if (verification.unavailable) {
1428
1607
  lastFailureReason = verification.reason;
1429
1608
  break;
@@ -1528,6 +1707,9 @@ export class NativeMacOSJobExecutor {
1528
1707
  await this.runCommand("open", ["-a", app, url]);
1529
1708
  }
1530
1709
  await this.focusApp(app);
1710
+ if (isSpotifySearchUrl(url)) {
1711
+ await this.ensureSafariSpotifySearchUrl(url);
1712
+ }
1531
1713
  this.lastActiveApp = app;
1532
1714
  return;
1533
1715
  }
@@ -1539,6 +1721,48 @@ export class NativeMacOSJobExecutor {
1539
1721
  }
1540
1722
  await this.runCommand("open", [url]);
1541
1723
  }
1724
+ async ensureSafariSpotifySearchUrl(url) {
1725
+ const targetUrl = normalizeUrl(url);
1726
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1727
+ await delay(700);
1728
+ const currentUrl = await this.readSafariCurrentUrl().catch(() => "");
1729
+ const normalizedCurrentUrl = normalizeComparableUrl(currentUrl);
1730
+ if (!normalizedCurrentUrl || !normalizedCurrentUrl.includes("open.spotify.com")) {
1731
+ return;
1732
+ }
1733
+ if (normalizedCurrentUrl.includes("/search/")) {
1734
+ return;
1735
+ }
1736
+ if (!/open\.spotify\.com\/?$/.test(normalizedCurrentUrl)) {
1737
+ return;
1738
+ }
1739
+ await this.setSafariFrontTabUrl(targetUrl).catch(() => undefined);
1740
+ }
1741
+ }
1742
+ async readSafariCurrentUrl() {
1743
+ const script = `
1744
+ tell application "Safari"
1745
+ if (count of windows) = 0 then return ""
1746
+ try
1747
+ return URL of current tab of front window
1748
+ on error
1749
+ return ""
1750
+ end try
1751
+ end tell
1752
+ `;
1753
+ const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
1754
+ return String(stdout || "").trim();
1755
+ }
1756
+ async setSafariFrontTabUrl(url) {
1757
+ const script = `
1758
+ set targetUrl to "${escapeAppleScript(url)}"
1759
+ tell application "Safari"
1760
+ if (count of windows) = 0 then return
1761
+ set URL of current tab of front window to targetUrl
1762
+ end tell
1763
+ `;
1764
+ await this.runCommand("osascript", ["-e", script]);
1765
+ }
1542
1766
  async tryReuseSafariTab(url) {
1543
1767
  const targetUrl = normalizeUrl(url);
1544
1768
  const targetHost = urlHostname(targetUrl);
@@ -1648,6 +1872,161 @@ end tell
1648
1872
  playerState: page.playerState || "",
1649
1873
  };
1650
1874
  }
1875
+ rememberSatisfiedSpotifyStep(description, confirmedPlaying) {
1876
+ if (!isSpotifySafariDomOnlyStep(description)) {
1877
+ return;
1878
+ }
1879
+ this.lastSatisfiedSpotifyDescription = description;
1880
+ this.lastSatisfiedSpotifyConfirmedPlaying = confirmedPlaying;
1881
+ this.lastSatisfiedSpotifyAt = Date.now();
1882
+ }
1883
+ hasRecentSatisfiedSpotifyPlayback(description, maxAgeMs = 10_000) {
1884
+ if (!this.lastSatisfiedSpotifyConfirmedPlaying || !this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
1885
+ return false;
1886
+ }
1887
+ if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
1888
+ return false;
1889
+ }
1890
+ return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
1891
+ }
1892
+ hasRecentSatisfiedSpotifyStep(description, maxAgeMs = 10_000) {
1893
+ if (!this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
1894
+ return false;
1895
+ }
1896
+ if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
1897
+ return false;
1898
+ }
1899
+ return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
1900
+ }
1901
+ async executeSpotifySafariDomStep(targetDescription, initialBrowserState) {
1902
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)
1903
+ && this.hasRecentSatisfiedSpotifyStep(targetDescription)
1904
+ && browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription)) {
1905
+ return {
1906
+ ok: true,
1907
+ skipped: true,
1908
+ afterState: initialBrowserState,
1909
+ };
1910
+ }
1911
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription) && this.hasRecentSatisfiedSpotifyPlayback(targetDescription)) {
1912
+ return {
1913
+ ok: true,
1914
+ skipped: true,
1915
+ confirmedPlaying: true,
1916
+ afterState: initialBrowserState,
1917
+ };
1918
+ }
1919
+ if (spotifyPlayerAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
1920
+ return {
1921
+ ok: true,
1922
+ skipped: true,
1923
+ confirmedPlaying: true,
1924
+ afterState: initialBrowserState,
1925
+ };
1926
+ }
1927
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
1928
+ const shouldWaitLongerForAutoplay = this.hasRecentSatisfiedSpotifyStep(targetDescription);
1929
+ let settledState = initialBrowserState;
1930
+ for (let attempt = 0; attempt < (shouldWaitLongerForAutoplay ? 6 : 3); attempt += 1) {
1931
+ if (attempt > 0) {
1932
+ await delay(shouldWaitLongerForAutoplay ? 550 : 450);
1933
+ }
1934
+ settledState = await this.captureBrowserPageState("Safari").catch(() => settledState);
1935
+ const playbackState = await this.readGlobalMediaPlaybackState();
1936
+ if ((playbackState === "playing" && spotifyStateContentMatchesDescription(settledState, targetDescription))
1937
+ || spotifyTrackPageLooksPlaying(settledState, targetDescription)
1938
+ || spotifyTrackAlreadyPlayingMatchesDescription(settledState, targetDescription)) {
1939
+ return {
1940
+ ok: true,
1941
+ skipped: true,
1942
+ confirmedPlaying: true,
1943
+ afterState: settledState,
1944
+ };
1945
+ }
1946
+ }
1947
+ const alreadyOnRequestedTrack = browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription);
1948
+ if (alreadyOnRequestedTrack) {
1949
+ const playbackState = await this.readGlobalMediaPlaybackState();
1950
+ if (playbackState === "playing"
1951
+ || spotifyTrackPageLooksPlaying(initialBrowserState, targetDescription)
1952
+ || spotifyTrackAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
1953
+ return {
1954
+ ok: true,
1955
+ skipped: true,
1956
+ confirmedPlaying: true,
1957
+ afterState: initialBrowserState,
1958
+ };
1959
+ }
1960
+ }
1961
+ }
1962
+ const domClick = await this.trySafariDomClick(targetDescription);
1963
+ let currentState = await this.captureBrowserPageState("Safari").catch(() => initialBrowserState);
1964
+ if (!domClick?.clicked) {
1965
+ return {
1966
+ ok: false,
1967
+ reason: domClick?.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`,
1968
+ afterState: currentState,
1969
+ };
1970
+ }
1971
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1972
+ if (attempt > 0) {
1973
+ await delay(500);
1974
+ }
1975
+ currentState = await this.captureBrowserPageState("Safari").catch(() => currentState);
1976
+ if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
1977
+ if (browserStateMatchesSpotifyTrackDescription(currentState, targetDescription)) {
1978
+ return {
1979
+ ok: true,
1980
+ click: domClick,
1981
+ afterState: currentState,
1982
+ };
1983
+ }
1984
+ const playbackState = await this.readGlobalMediaPlaybackState();
1985
+ if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
1986
+ return {
1987
+ ok: true,
1988
+ click: domClick,
1989
+ confirmedPlaying: true,
1990
+ afterState: currentState,
1991
+ };
1992
+ }
1993
+ }
1994
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
1995
+ if (spotifyPlayerAlreadyPlayingMatchesDescription(currentState, targetDescription)) {
1996
+ return {
1997
+ ok: true,
1998
+ click: domClick,
1999
+ confirmedPlaying: true,
2000
+ afterState: currentState,
2001
+ };
2002
+ }
2003
+ const playbackState = await this.readGlobalMediaPlaybackState();
2004
+ if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
2005
+ return {
2006
+ ok: true,
2007
+ click: domClick,
2008
+ confirmedPlaying: true,
2009
+ afterState: currentState,
2010
+ };
2011
+ }
2012
+ }
2013
+ }
2014
+ if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
2015
+ const matchedHref = normalizeComparableUrl(domClick.matchedHref || "");
2016
+ if (matchedHref.includes("/track/")) {
2017
+ return {
2018
+ ok: true,
2019
+ click: domClick,
2020
+ afterState: currentState,
2021
+ };
2022
+ }
2023
+ }
2024
+ return {
2025
+ ok: false,
2026
+ reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
2027
+ afterState: currentState,
2028
+ };
2029
+ }
1651
2030
  resolveExpectedBrowserHref(rawHref, baseUrl) {
1652
2031
  const href = String(rawHref || "").trim();
1653
2032
  if (!href) {
@@ -1688,12 +2067,14 @@ end tell
1688
2067
  const afterPlayerTitle = normalizeText(after.playerTitle || "");
1689
2068
  const beforePlayerState = normalizeText(before?.playerState || "");
1690
2069
  const afterPlayerState = normalizeText(after.playerState || "");
1691
- const playerLooksActive = afterPlayerState.includes("pause") || afterPlayerState.includes("pausar");
2070
+ const playerLooksActive = playerStateLooksActive(after.playerState || "");
1692
2071
  const playerLooksPaused = !playerLooksActive && /play|tocar|reproduzir|continuar|retomar|resume/.test(afterPlayerState);
1693
2072
  const wantsNext = descriptionWantsNext(targetDescription);
1694
2073
  const wantsPrevious = descriptionWantsPrevious(targetDescription);
1695
2074
  const wantsPause = descriptionWantsPause(targetDescription);
1696
2075
  const wantsResume = descriptionWantsResume(targetDescription);
2076
+ const wantsSpotifyTrackPageOpen = descriptionWantsSpotifyTrackPageOpen(targetDescription);
2077
+ const wantsSpotifyTrackPagePlay = descriptionWantsSpotifyTrackPagePlay(targetDescription);
1697
2078
  const mediaQueryTokens = extractMediaQueryTokens(targetDescription);
1698
2079
  const mediaMatchCount = countMatchingTokens(after.playerTitle || "", mediaQueryTokens);
1699
2080
  if (afterUrl.includes("music.youtube.com")) {
@@ -1719,6 +2100,51 @@ end tell
1719
2100
  return true;
1720
2101
  }
1721
2102
  }
2103
+ if (afterUrl.includes("open.spotify.com")) {
2104
+ if (wantsSpotifyTrackPageOpen && spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription)) {
2105
+ return true;
2106
+ }
2107
+ if (wantsSpotifyTrackPagePlay && spotifyTrackAlreadyPlayingMatchesDescription(after, targetDescription)) {
2108
+ return true;
2109
+ }
2110
+ if (wantsSpotifyTrackPageOpen) {
2111
+ const beforeWasTrackPage = beforeUrl.includes("/track/");
2112
+ const afterIsTrackPage = afterUrl.includes("/track/");
2113
+ if (afterIsTrackPage && (!beforeWasTrackPage || beforeUrl !== afterUrl)) {
2114
+ return true;
2115
+ }
2116
+ return spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription);
2117
+ }
2118
+ if (wantsPause && beforePlayerState && beforePlayerState !== afterPlayerState && playerLooksPaused) {
2119
+ return true;
2120
+ }
2121
+ if (wantsResume && playerLooksActive && beforePlayerState !== afterPlayerState) {
2122
+ return true;
2123
+ }
2124
+ if ((wantsNext || wantsPrevious) && beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle) {
2125
+ return true;
2126
+ }
2127
+ if (mediaQueryTokens.length >= 2) {
2128
+ const requiredMatches = Math.max(2, Math.ceil(mediaQueryTokens.length * 0.5));
2129
+ const titleLooksCorrect = mediaMatchCount >= requiredMatches;
2130
+ const playerChanged = ((beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle)
2131
+ || (!beforePlayerTitle && !!afterPlayerTitle)
2132
+ || (beforePlayerState && afterPlayerState && beforePlayerState !== afterPlayerState));
2133
+ if (titleLooksCorrect && (playerChanged || (beforePlayerTitle === afterPlayerTitle && playerLooksActive))) {
2134
+ return true;
2135
+ }
2136
+ }
2137
+ if (beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle && playerLooksActive) {
2138
+ return true;
2139
+ }
2140
+ if (!beforePlayerTitle && afterPlayerTitle && playerLooksActive) {
2141
+ return true;
2142
+ }
2143
+ }
2144
+ const looksLikeMediaAction = wantsNext || wantsPrevious || wantsPause || wantsResume || mediaQueryTokens.length > 0;
2145
+ if (looksLikeMediaAction && (afterUrl.includes("music.youtube.com") || afterUrl.includes("open.spotify.com"))) {
2146
+ return false;
2147
+ }
1722
2148
  const beforeTitle = normalizeText(before?.title || "");
1723
2149
  const afterTitle = normalizeText(after.title || "");
1724
2150
  if (beforeTitle && afterTitle && beforeTitle !== afterTitle) {
@@ -1739,9 +2165,11 @@ end tell
1739
2165
  afterState: null,
1740
2166
  };
1741
2167
  }
2168
+ let lastAfterState = null;
1742
2169
  for (let attempt = 0; attempt < 4; attempt += 1) {
1743
2170
  await delay(attempt === 0 ? 900 : 700);
1744
2171
  const afterState = await this.captureBrowserPageState(app).catch(() => null);
2172
+ lastAfterState = afterState;
1745
2173
  if (this.didBrowserPageStateChange(before, afterState, targetDescription, matchedHref)) {
1746
2174
  return {
1747
2175
  ok: true,
@@ -1753,7 +2181,7 @@ end tell
1753
2181
  return {
1754
2182
  ok: false,
1755
2183
  reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
1756
- afterState: null,
2184
+ afterState: lastAfterState,
1757
2185
  };
1758
2186
  }
1759
2187
  async pressShortcut(shortcut) {
@@ -1775,8 +2203,28 @@ end tell
1775
2203
  };
1776
2204
  const mediaCommand = mediaCommandMap[normalizedShortcut];
1777
2205
  if (mediaCommand) {
2206
+ if (normalizedShortcut === "media_pause") {
2207
+ const playbackState = await this.readGlobalMediaPlaybackState();
2208
+ if (playbackState !== "playing") {
2209
+ return {
2210
+ performed: false,
2211
+ reason: playbackState === "unknown"
2212
+ ? "O macOS nao confirmou nenhuma mídia tocando com segurança."
2213
+ : "Nenhuma mídia estava tocando no macOS.",
2214
+ };
2215
+ }
2216
+ }
2217
+ if (normalizedShortcut === "media_resume" || normalizedShortcut === "media_play") {
2218
+ const playbackState = await this.readGlobalMediaPlaybackState();
2219
+ if (playbackState === "playing") {
2220
+ return {
2221
+ performed: false,
2222
+ reason: "Já havia mídia tocando no macOS.",
2223
+ };
2224
+ }
2225
+ }
1778
2226
  await this.triggerMacOSMediaTransport(mediaCommand);
1779
- return;
2227
+ return { performed: true };
1780
2228
  }
1781
2229
  const namedKeyCodes = {
1782
2230
  return: 36,
@@ -1796,12 +2244,67 @@ end tell
1796
2244
  "-e",
1797
2245
  `tell application "System Events" to key code ${namedKeyCodes[key]}${usingClause}`,
1798
2246
  ]);
1799
- return;
2247
+ return { performed: true };
1800
2248
  }
1801
2249
  await this.runCommand("osascript", [
1802
2250
  "-e",
1803
2251
  `tell application "System Events" to keystroke "${escapeAppleScript(key)}"${usingClause}`,
1804
2252
  ]);
2253
+ return { performed: true };
2254
+ }
2255
+ async readGlobalMediaPlaybackState() {
2256
+ const swiftScript = `
2257
+ import Foundation
2258
+ import Dispatch
2259
+ import Darwin
2260
+
2261
+ typealias MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction = @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
2262
+
2263
+ guard let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) else {
2264
+ print("unknown")
2265
+ exit(0)
2266
+ }
2267
+
2268
+ guard let symbol = dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") else {
2269
+ print("unknown")
2270
+ exit(0)
2271
+ }
2272
+
2273
+ let fn = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction.self)
2274
+ let semaphore = DispatchSemaphore(value: 0)
2275
+ var isPlaying: Bool? = nil
2276
+
2277
+ fn(DispatchQueue.global()) { value in
2278
+ isPlaying = value
2279
+ semaphore.signal()
2280
+ }
2281
+
2282
+ if semaphore.wait(timeout: .now() + .milliseconds(800)) == .timedOut {
2283
+ print("unknown")
2284
+ exit(0)
2285
+ }
2286
+
2287
+ print(isPlaying == true ? "playing" : "not_playing")
2288
+ `;
2289
+ try {
2290
+ const { stdout } = await this.runCommandCapture("swift", [
2291
+ "-module-cache-path",
2292
+ path.join(os.tmpdir(), "otto-bridge-swift-modcache"),
2293
+ "-e",
2294
+ swiftScript,
2295
+ ]);
2296
+ const state = normalizeText(stdout || "").trim();
2297
+ if (state.includes("not_playing")) {
2298
+ return "not_playing";
2299
+ }
2300
+ if (state.includes("playing")) {
2301
+ return "playing";
2302
+ }
2303
+ return "unknown";
2304
+ }
2305
+ catch {
2306
+ return "unknown";
2307
+ }
1805
2308
  }
1806
2309
  async triggerMacOSMediaTransport(command) {
1807
2310
  const keyTypeMap = {
@@ -2886,6 +3389,14 @@ const wantsNext = /\\b(proxim[ao]?|next|skip|pular|avanca|avancar)\\b/.test(norm
2886
3389
  const wantsPrevious = /\\b(anterior|previous|volta[ar]?|back|retorna[ar]?)\\b/.test(normalizedDescription);
2887
3390
  const wantsPause = /\\b(pausa|pause|pausar)\\b/.test(normalizedDescription);
2888
3391
  const wantsResume = /\\b(retoma|retomar|resume|continu[ae]r|despausa|play)\\b/.test(normalizedDescription);
3392
+ const wantsSpotifyTrackPageOpen = location.hostname.includes("open.spotify.com")
3393
+ && /\\bspotify\\b/.test(normalizedDescription)
3394
+ && /\\b(pagina|titulo|nome|link)\\b/.test(normalizedDescription)
3395
+ && /\\b(faixa|musica|track)\\b/.test(normalizedDescription);
3396
+ const wantsSpotifyTrackPagePlay = location.hostname.includes("open.spotify.com")
3397
+ && /\\bspotify\\b/.test(normalizedDescription)
3398
+ && /\\b(play|reproduzir|tocar|verde|principal)\\b/.test(normalizedDescription)
3399
+ && /\\b(pagina|faixa|musica|track)\\b/.test(normalizedDescription);
2889
3400
  const stopWords = new Set([
2890
3401
  "o", "a", "os", "as", "um", "uma", "uns", "umas", "de", "da", "do", "das", "dos",
2891
3402
  "em", "no", "na", "nos", "nas", "para", "por", "com", "que", "visivel", "visiveis",
@@ -2995,6 +3506,33 @@ function clickElement(element, strategy, matchedText, matchedHref, score, totalC
2995
3506
  };
2996
3507
  }
2997
3508
 
3509
+ function pickYouTubeMusicPrimaryCard(element) {
3510
+ let current = element instanceof Element ? element : null;
3511
+ let best = null;
3512
+ let bestScore = -Infinity;
3513
+ while (current && current instanceof HTMLElement && current !== document.body) {
3514
+ const rect = current.getBoundingClientRect();
3515
+ const text = normalize(deriveText(current));
3516
+ let score = 0;
3517
+ if (rect.width >= window.innerWidth * 0.32) score += 40;
3518
+ if (rect.width >= window.innerWidth * 0.42) score += 80;
3519
+ if (rect.left <= window.innerWidth * 0.18) score += 90;
3520
+ if ((rect.left + rect.width) <= window.innerWidth * 0.68) score += 120;
3521
+ if (rect.top <= window.innerHeight * 0.7) score += 40;
3522
+ if (text.includes("mais do youtube")) score -= 260;
3523
+ if (text.includes("playlist")) score -= 40;
3524
+ if (text.includes("podcast")) score -= 40;
3525
+ if (text.includes("musica")) score += 28;
3526
+ if (text.includes("video")) score -= 18;
3527
+ if (score > bestScore) {
3528
+ bestScore = score;
3529
+ best = current;
3530
+ }
3531
+ current = current.parentElement;
3532
+ }
3533
+ return best;
3534
+ }
3535
+
2998
3536
  function attemptYouTubeMusicTransportClick() {
2999
3537
  if (!isYouTubeMusic || !(wantsNext || wantsPrevious || wantsPause || wantsResume)) {
3000
3538
  return null;
@@ -3042,6 +3580,97 @@ function attemptYouTubeMusicSearchResultClick() {
3042
3580
  return null;
3043
3581
  }
3044
3582
 
3583
+ const primaryCardButtons = Array.from(document.querySelectorAll("button, [role='button'], a[href*='watch?v=']"))
3584
+ .filter((node) => node instanceof HTMLElement || node instanceof HTMLAnchorElement)
3585
+ .filter((node) => isVisible(node))
3586
+ .filter((node) => !(node.closest("ytmusic-player-bar")))
3587
+ .map((node) => {
3588
+ const element = node;
3589
+ const rect = element.getBoundingClientRect();
3590
+ const primaryCard = pickYouTubeMusicPrimaryCard(element);
3591
+ const primaryCardRect = primaryCard ? primaryCard.getBoundingClientRect() : rect;
3592
+ const label = normalize([
3593
+ deriveText(element),
3594
+ element.getAttribute("aria-label"),
3595
+ element.getAttribute("title"),
3596
+ ].filter(Boolean).join(" "));
3597
+ const cardText = normalize(primaryCard ? deriveText(primaryCard) : deriveText(element.parentElement || element));
3598
+ const isPlayButton = /\b(play|reproduzir|tocar)\b/.test(label);
3599
+ const buttonCenterX = rect.left + (rect.width / 2);
3600
+ const cardCenterX = primaryCardRect.left + (primaryCardRect.width / 2);
3601
+ let score = 0;
3602
+
3603
+ if (!isPlayButton) {
3604
+ return null;
3605
+ }
3606
+ if (buttonCenterX > (window.innerWidth * 0.44)) {
3607
+ return null;
3608
+ }
3609
+ if (rect.width < 110 || rect.height < 36) {
3610
+ return null;
3611
+ }
3612
+ if (primaryCardRect.left > (window.innerWidth * 0.16)) {
3613
+ return null;
3614
+ }
3615
+ if (cardCenterX > (window.innerWidth * 0.36)) {
3616
+ return null;
3617
+ }
3618
+ if (primaryCardRect.width < (window.innerWidth * 0.28)) {
3619
+ return null;
3620
+ }
3621
+
3622
+ for (const phrase of quotedPhrases) {
3623
+ if (!phrase) continue;
3624
+ if (cardText.includes(phrase) || label.includes(phrase)) score += 220;
3625
+ }
3626
+
3627
+ for (const token of tokens) {
3628
+ if (cardText.includes(token)) score += 30;
3629
+ else if (label.includes(token)) score += 16;
3630
+ }
3631
+
3632
+ if (tokens.length > 1) {
3633
+ const matchCount = tokens.filter((token) => cardText.includes(token)).length;
3634
+ if (matchCount >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 120;
3635
+ if (matchCount === tokens.length) score += 80;
3636
+ }
3637
+
3638
+ if ((primaryCardRect.left + (primaryCardRect.width / 2)) < (window.innerWidth * 0.48)) score += 320;
3639
+ if (primaryCardRect.left <= window.innerWidth * 0.18) score += 140;
3640
+ if (primaryCardRect.width >= window.innerWidth * 0.38) score += 120;
3641
+ if ((primaryCardRect.left + primaryCardRect.width) <= window.innerWidth * 0.7) score += 160;
3642
+ if (buttonCenterX < (window.innerWidth * 0.34)) score += 220;
3643
+ if (buttonCenterX < (window.innerWidth * 0.4)) score += 120;
3644
+ if (rect.top < (window.innerHeight * 0.7)) score += 90;
3645
+ if (rect.width >= 120) score += 36;
3646
+ if (rect.height >= 44) score += 18;
3647
+ if (cardText.includes("mais do youtube")) score -= 320;
3648
+ if (primaryCardRect.left > window.innerWidth * 0.45) score -= 260;
3649
+ if (buttonCenterX > window.innerWidth * 0.42) score -= 360;
3650
+ score += Math.max(0, 40 - Math.round(rect.top / 20));
3651
+
3652
+ return score > 0 ? {
3653
+ node: element,
3654
+ score,
3655
+ label,
3656
+ matchedText: primaryCard ? deriveText(primaryCard) : deriveText(element),
3657
+ } : null;
3658
+ })
3659
+ .filter(Boolean)
3660
+ .sort((left, right) => right.score - left.score);
3661
+
3662
+ if (primaryCardButtons.length) {
3663
+ const winner = primaryCardButtons[0];
3664
+ return clickElement(
3665
+ winner.node,
3666
+ "safari_dom_ytmusic_primary_card",
3667
+ winner.matchedText || winner.label,
3668
+ "",
3669
+ winner.score,
3670
+ primaryCardButtons.length,
3671
+ );
3672
+ }
3673
+
3045
3674
  const rows = Array.from(document.querySelectorAll("ytmusic-responsive-list-item-renderer"))
3046
3675
  .filter((node) => node instanceof HTMLElement)
3047
3676
  .filter((node) => isVisible(node));
@@ -3134,6 +3763,215 @@ if (ytmResult) {
3134
3763
  return ytmResult;
3135
3764
  }
3136
3765
 
3766
+ function attemptSpotifyTrackPagePlayClick() {
3767
+ if (!location.hostname.includes("open.spotify.com")) {
3768
+ return null;
3769
+ }
3770
+ if (!location.pathname.includes("/track/")) {
3771
+ return null;
3772
+ }
3773
+
3774
+ const rankedButtons = Array.from(document.querySelectorAll(
3775
+ "main [data-testid='play-button'], main button[aria-label], main button[title], [data-testid='entityHeader'] button, main button"
3776
+ ))
3777
+ .filter((node) => node instanceof HTMLElement)
3778
+ .filter((node) => isVisible(node))
3779
+ .filter((node) => !(node.closest("footer")))
3780
+ .map((node, index) => {
3781
+ const rect = node.getBoundingClientRect();
3782
+ const label = normalize([
3783
+ deriveText(node),
3784
+ node.getAttribute("aria-label"),
3785
+ node.getAttribute("title"),
3786
+ node.getAttribute("data-testid"),
3787
+ ].filter(Boolean).join(" "));
3788
+ const background = normalize(window.getComputedStyle(node).backgroundColor || "");
3789
+ let score = 0;
3790
+
3791
+ if (!/play|tocar|reproduzir/.test(label)) {
3792
+ return null;
3793
+ }
3794
+
3795
+ if (node.getAttribute("data-testid") === "play-button") score += 220;
3796
+ if (label.includes("play")) score += 100;
3797
+ if (background.includes("29, 185, 84") || background.includes("30, 215, 96")) score += 180;
3798
+ if (rect.top <= window.innerHeight * 0.72) score += 90;
3799
+ if (rect.top <= window.innerHeight * 0.56) score += 70;
3800
+ if (rect.left <= window.innerWidth * 0.4) score += 80;
3801
+ if (rect.width >= 44) score += 30;
3802
+ if (rect.height >= 44) score += 30;
3803
+ if (node.closest("[data-testid='entityHeader'], main")) score += 60;
3804
+ score += Math.max(0, 18 - index);
3805
+
3806
+ return score > 0 ? { node, label, score } : null;
3807
+ })
3808
+ .filter(Boolean)
3809
+ .sort((left, right) => right.score - left.score);
3810
+
3811
+ if (!rankedButtons.length) {
3812
+ return null;
3813
+ }
3814
+
3815
+ const winner = rankedButtons[0];
3816
+ return clickElement(
3817
+ winner.node,
3818
+ "safari_dom_spotify_track_page_play",
3819
+ winner.label,
3820
+ location.href || "",
3821
+ winner.score,
3822
+ rankedButtons.length,
3823
+ );
3824
+ }
3825
+
3826
+ const spotifyTrackPagePlay = attemptSpotifyTrackPagePlayClick();
3827
+ if (spotifyTrackPagePlay) {
3828
+ return spotifyTrackPagePlay;
3829
+ }
3830
+
3831
+ function attemptSpotifySearchResultClick() {
3832
+ if (!location.hostname.includes("open.spotify.com")) {
3833
+ return null;
3834
+ }
3835
+ if (!quotedPhrases.length && !tokens.length) {
3836
+ return null;
3837
+ }
3838
+
3839
+ const rows = Array.from(document.querySelectorAll(
3840
+ "[data-testid='tracklist-row'], [role='row']"
3841
+ ))
3842
+ .filter((node) => node instanceof HTMLElement)
3843
+ .filter((node) => isVisible(node));
3844
+
3845
+ const rankedRows = rows
3846
+ .map((row, index) => {
3847
+ const titleNode = row.querySelector(
3848
+ "[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
3849
+ );
3850
+ const navigationTarget = titleNode instanceof HTMLAnchorElement || titleNode instanceof HTMLElement
3851
+ ? titleNode
3852
+ : null;
3853
+ const titleText = String((titleNode && titleNode.textContent) || "").trim();
3854
+ const rowText = deriveText(row);
3855
+ const normalizedTitle = normalize(titleText);
3856
+ const normalizedRow = normalize(rowText);
3857
+ let score = 0;
3858
+
3859
+ for (const phrase of quotedPhrases) {
3860
+ if (!phrase) continue;
3861
+ if (normalizedTitle.includes(phrase)) score += 180;
3862
+ else if (normalizedRow.includes(phrase)) score += 110;
3863
+ }
3864
+
3865
+ for (const token of tokens) {
3866
+ if (normalizedTitle.includes(token)) score += 26;
3867
+ else if (normalizedRow.includes(token)) score += 10;
3868
+ }
3869
+
3870
+ const titleMatches = tokens.filter((token) => normalizedTitle.includes(token)).length;
3871
+ if (tokens.length > 1 && titleMatches >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 90;
3872
+ if (titleMatches === tokens.length && tokens.length > 0) score += 70;
3873
+ if (index < 4) score += Math.max(0, 24 - (index * 5));
3874
+
3875
+ const playCandidate = Array.from(row.querySelectorAll("button, [role='button'], a[href*='/track/']"))
3876
+ .filter((candidate) => candidate instanceof HTMLElement || candidate instanceof HTMLAnchorElement)
3877
+ .filter((candidate) => isVisible(candidate))
3878
+ .map((candidate) => {
3879
+ const label = normalize([
3880
+ deriveText(candidate),
3881
+ candidate.getAttribute("aria-label"),
3882
+ candidate.getAttribute("title"),
3883
+ ].filter(Boolean).join(" "));
3884
+ let candidateScore = 0;
3885
+ if (/play|tocar|reproduzir/.test(label)) candidateScore += 40;
3886
+ if (candidate instanceof HTMLAnchorElement && normalize(candidate.href).includes("/track/")) candidateScore += 18;
3887
+ return { candidate, candidateScore };
3888
+ })
3889
+ .sort((left, right) => right.candidateScore - left.candidateScore);
3890
+
3891
+ if (wantsSpotifyTrackPageOpen && !navigationTarget) {
3892
+ return null;
3893
+ }
3894
+
3895
+ return score > 0 ? {
3896
+ row,
3897
+ titleText,
3898
+ href: titleNode instanceof HTMLAnchorElement ? titleNode.href : "",
3899
+ score: score + (playCandidate[0]?.candidateScore || 0),
3900
+ target: wantsSpotifyTrackPageOpen
3901
+ ? navigationTarget
3902
+ : (playCandidate[0]?.candidate || navigationTarget || row),
3903
+ } : null;
3904
+ })
3905
+ .filter(Boolean)
3906
+ .sort((left, right) => right.score - left.score);
3907
+
3908
+ if (!rankedRows.length) {
3909
+ return null;
3910
+ }
3911
+
3912
+ const winner = rankedRows[0];
3913
+ const spotifyRow = winner.row instanceof HTMLElement ? winner.row : null;
3914
+ const titleCandidate = spotifyRow?.querySelector(
3915
+ "[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
3916
+ );
3917
+ const explicitPlayButton = spotifyRow
3918
+ ? Array.from(spotifyRow.querySelectorAll("button, [role='button']"))
3919
+ .filter((node) => node instanceof HTMLElement)
3920
+ .filter((node) => isVisible(node))
3921
+ .find((node) => {
3922
+ const label = normalize([
3923
+ deriveText(node),
3924
+ node.getAttribute("aria-label"),
3925
+ node.getAttribute("title"),
3926
+ ].filter(Boolean).join(" "));
3927
+ return /\b(play|tocar|reproduzir)\b/.test(label);
3928
+ })
3929
+ : null;
3930
+
3931
+ const clickTarget = wantsSpotifyTrackPageOpen
3932
+ ? (titleCandidate || winner.target)
3933
+ : (explicitPlayButton || titleCandidate || winner.target);
3934
+ const clicked = clickElement(
3935
+ clickTarget,
3936
+ wantsSpotifyTrackPageOpen
3937
+ ? "safari_dom_spotify_track_link"
3938
+ : (explicitPlayButton ? "safari_dom_spotify_play_button" : "safari_dom_spotify_result"),
3939
+ winner.titleText || deriveText(winner.row),
3940
+ winner.href || "",
3941
+ winner.score,
3942
+ rankedRows.length,
3943
+ );
3944
+ if (!clicked) {
3945
+ return null;
3946
+ }
3947
+ if (spotifyRow instanceof HTMLElement && !explicitPlayButton && !wantsSpotifyTrackPageOpen) {
3948
+ const rect = spotifyRow.getBoundingClientRect();
3949
+ spotifyRow.dispatchEvent(new MouseEvent("dblclick", {
3950
+ bubbles: true,
3951
+ cancelable: true,
3952
+ view: window,
3953
+ clientX: rect.left + (rect.width / 2),
3954
+ clientY: rect.top + (rect.height / 2),
3955
+ }));
3956
+ }
3957
+ return clicked;
3958
+ }
3959
+
3960
+ const spotifyResult = attemptSpotifySearchResultClick();
3961
+ if (spotifyResult) {
3962
+ return spotifyResult;
3963
+ }
3964
+
3965
+ if (location.hostname.includes("open.spotify.com") && (wantsSpotifyTrackPageOpen || wantsSpotifyTrackPagePlay)) {
3966
+ return {
3967
+ clicked: false,
3968
+ reason: wantsSpotifyTrackPageOpen
3969
+ ? "Nenhuma faixa visivel do Spotify Web combinou com a busca atual; nao vou clicar em abas, playlists ou albuns por fallback."
3970
+ : "O Spotify Web nao mostrou um botao principal de reproduzir confiavel; nao vou clicar em elementos genericos por fallback.",
3971
+ strategy: "safari_dom_spotify_no_fallback",
3972
+ };
3973
+ }
3974
+
3137
3975
  function scoreCandidate(element, rank) {
3138
3976
  const text = deriveText(element);
3139
3977
  const href = element instanceof HTMLAnchorElement
@@ -3254,7 +4092,7 @@ tell application "Safari"
3254
4092
  activate
3255
4093
  if (count of windows) = 0 then error "Safari nao possui janelas abertas."
3256
4094
  delay 1
3257
- set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');const playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';const playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';return JSON.stringify({title,url,text,playerTitle,playerState});})();"
4095
+ set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const isYouTubeMusic=location.hostname.includes('music.youtube.com');const isSpotify=location.hostname.includes('open.spotify.com');let playerButton=null;let playerTitle='';let playerState='';if(isYouTubeMusic){playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';}else if(isSpotify){const visible=(node)=>{if(!(node instanceof Element))return false;const rect=node.getBoundingClientRect();if(rect.width<4||rect.height<4)return false;const style=window.getComputedStyle(node);if(style.visibility==='hidden'||style.display==='none'||Number(style.opacity||'1')===0)return false;return rect.bottom>=0&&rect.right>=0&&rect.top<=window.innerHeight&&rect.left<=window.innerWidth;};const spotifyTitleCandidates=Array.from(document.querySelectorAll(\"[data-testid='nowplaying-track-link'], footer a[href*='/track/'], [data-testid='now-playing-widget'] a[href*='/track/'], a[href*='/track/']\")).filter((node)=>visible(node)).map((node)=>({node,text:((node&&node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>entry.text).sort((left,right)=>{const leftBottomBias=(left.rect.top>=window.innerHeight*0.72?200:0)+(left.rect.left<=window.innerWidth*0.45?120:0)+left.rect.top;const rightBottomBias=(right.rect.top>=window.innerHeight*0.72?200:0)+(right.rect.left<=window.innerWidth*0.45?120:0)+right.rect.top;return rightBottomBias-leftBottomBias;});playerTitle=(spotifyTitleCandidates[0]&&spotifyTitleCandidates[0].text)||'';playerButton=Array.from(document.querySelectorAll(\"footer button, [data-testid='control-button-playpause'], button[aria-label], button[title]\")).filter((node)=>visible(node)).map((node)=>({node,label:((node.getAttribute('aria-label')||node.getAttribute('title')||node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>/play|pause|tocar|pausar|reproduzir/i.test(entry.label)).sort((left,right)=>{const leftScore=(left.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((left.rect.left+left.rect.width/2)-(window.innerWidth/2)));const rightScore=(right.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((right.rect.left+right.rect.width/2)-(window.innerWidth/2)));return rightScore-leftScore;})[0]?.node||null;playerState=(playerButton&&((playerButton.getAttribute('aria-label')||playerButton.getAttribute('title')||playerButton.textContent)||'').trim())||'';}return JSON.stringify({title,url,text,playerTitle,playerState});})();"
3258
4096
  set pageJson to do JavaScript jsCode in current tab of front window
3259
4097
  end tell
3260
4098
  return pageJson