diff --git a/README.md b/README.md index 133b3b6..8696a34 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,11 @@ soundhealing_sonicflow/ | Platform | Runtime/UI status | Audio source path | System audio capture | Notes | |---|---|---|---|---| -| Chrome extension | FlowTones-style popup with preset-first controls, beat volume, duration, ambient mix, pulse depth | Web tab audio via content script + shared JS beat engine | No | Browser-safe shell only. No native render/export promises. | +| Chrome extension | FlowTones-style popup with preset-first controls, Overlay Mode, beat volume, duration, ambient mix, pulse depth | Web tab audio via content script + shared JS beat engine | No | Browser-safe overlay for tabs with media elements. No native render/export promises. | | Safari extension (web shell) | Leopard-backed FlowTones-style wrapper messaging | Web extension runtime shell | No dedicated system capture path | Mirrors product language, but native-only features belong in the app targets. | -| iOS app | Native FlowTones settings model plus upgraded screen state and advanced controls | Local file + generated beat layer | No | Closest parity target for the standalone FlowTones runtime. | -| macOS app | Leopard-native menu-bar popover with starter sessions, preset metadata, and advanced controls | Local file + generated beat layer | Partial/limited | Native app now mirrors the preset-first FlowTones direction more closely than the Safari web shell. | -| Android app | FlowTones-style session model in ViewModel/service path plus advanced controls in Compose | Local session/service + generated beat layer | No | Duration, ambient mix, and pulse depth currently travel as session metadata; synthesis parity is still incremental. | +| iOS app | Native FlowTones settings model plus upgraded screen state, Overlay Mode status, and advanced controls | Local file + generated beat layer | No | Spotify/YouTube system overlay is unavailable; picked files remain the local overlay path. | +| macOS app | Leopard-native menu-bar popover with starter sessions, Overlay Mode, preset metadata, and advanced controls | Local file/system capture + generated beat layer | Partial/limited | Native app exposes permitted system overlay capture with file fallback. | +| Android app | FlowTones-style session model in ViewModel/service path plus Overlay Mode status and advanced controls in Compose | Local session/service + generated beat layer | No | External app capture needs platform/store policy review; local sessions remain available. | ## Brand Asset Usage @@ -163,7 +163,7 @@ Parallel work is supported via agents for independent tickets, with one branch/P ## Known Limitations -- iOS and Android targets do not implement Spotify/system-output capture in this repository. +- iOS and Android targets do not implement Spotify/system-output capture in this repository; their Overlay Mode copy calls out local-session support and policy limits. - Safari behavior can differ between iOS Safari and macOS Safari due to Web Extension API differences. - Browser shells expose FlowTones-style controls, but they do not yet offer native offline render/export or cache flows. - Android currently carries `durationMinutes`, `ambientMix`, and `pulseDepth` through the session model and UI, but the underlying audio engine is not yet feature-parity with the Apple-native runtime. diff --git a/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/FlowTonesViewModel.kt b/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/FlowTonesViewModel.kt index a4e6d62..fe7432c 100644 --- a/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/FlowTonesViewModel.kt +++ b/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/FlowTonesViewModel.kt @@ -42,6 +42,11 @@ class FlowTonesViewModel @Inject constructor( private val mutableSelectedFile = MutableStateFlow(null) val selectedFile: StateFlow = mutableSelectedFile.asStateFlow() + private val mutableOverlayModeStatus = MutableStateFlow( + "Overlay Mode: Android external app capture requires policy review; local sessions remain available." + ) + val overlayModeStatus: StateFlow = mutableOverlayModeStatus.asStateFlow() + private val mutableFilePickerEvents = MutableSharedFlow() val filePickerEvents: SharedFlow = mutableFilePickerEvents.asSharedFlow() diff --git a/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/MainScreen.kt b/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/MainScreen.kt index 8831a97..0c5c09c 100644 --- a/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/MainScreen.kt +++ b/sonicflow_app/android-app/app/src/main/java/com/sonicflow/app/ui/MainScreen.kt @@ -45,6 +45,7 @@ fun MainScreen(viewModel: FlowTonesViewModel) { val ambientMix by viewModel.ambientMix.collectAsState() val pulseDepth by viewModel.pulseDepth.collectAsState() val selectedFile by viewModel.selectedFile.collectAsState() + val overlayModeStatus by viewModel.overlayModeStatus.collectAsState() val accent = currentMode.modeColor @@ -196,6 +197,12 @@ fun MainScreen(viewModel: FlowTonesViewModel) { Text(if (selectedFile.isNullOrBlank()) "Pick file" else "Change file") } + AssistChip( + onClick = {}, + label = { Text(overlayModeStatus) }, + modifier = Modifier.fillMaxWidth() + ) + Button( onClick = { if (isActive) viewModel.stopSession() else viewModel.startSession() }, modifier = Modifier.fillMaxWidth() diff --git a/sonicflow_app/android-app/app/src/test/java/com/sonicflow/app/ui/FlowTonesViewModelTest.kt b/sonicflow_app/android-app/app/src/test/java/com/sonicflow/app/ui/FlowTonesViewModelTest.kt index 1f5f206..d623112 100644 --- a/sonicflow_app/android-app/app/src/test/java/com/sonicflow/app/ui/FlowTonesViewModelTest.kt +++ b/sonicflow_app/android-app/app/src/test/java/com/sonicflow/app/ui/FlowTonesViewModelTest.kt @@ -87,6 +87,17 @@ class FlowTonesViewModelTest { assertEquals(FlowMode.SLEEP, viewModel.currentMode.value) } + @Test + fun `overlay mode status is explicit about Android source capture limits`() = runTest { + val controller = FakeController() + val viewModel = FlowTonesViewModel(controller) + + assertEquals( + "Overlay Mode: Android external app capture requires policy review; local sessions remain available.", + viewModel.overlayModeStatus.value + ) + } + @Test fun `applyExample loads starter session settings immediately`() = runTest { val controller = FakeController() diff --git a/sonicflow_app/chrome-extension/popup-model.js b/sonicflow_app/chrome-extension/popup-model.js index f1c4c59..d2c7cb9 100644 --- a/sonicflow_app/chrome-extension/popup-model.js +++ b/sonicflow_app/chrome-extension/popup-model.js @@ -67,8 +67,50 @@ export const EXAMPLES = [ } ]; +export const OVERLAY_SOURCES = [ + { + id: "browser-tab", + label: "Browser tab", + status: "Available", + description: "Layer SonicFlow under YouTube, SoundCloud, and pages with audio or video." + }, + { + id: "macos-system", + label: "macOS system", + status: "Native app", + description: "Use the macOS app for permitted system audio capture." + }, + { + id: "ios-local", + label: "iOS local", + status: "Local only", + description: "Picked files can layer with SonicFlow. Spotify and YouTube capture are unavailable." + }, + { + id: "android-policy-review", + label: "Android", + status: "Policy review", + description: "External app capture needs platform and store policy review." + } +]; + +export function resolveOverlayStatus(tabState) { + if (tabState?.pageHasAudioSource) { + return { + ready: true, + message: "Overlay ready for this browser tab." + }; + } + + return { + ready: false, + message: "Open YouTube, SoundCloud, or another supported media tab first." + }; +} + export const DEFAULT_SETTINGS = { mode: "focus", + overlaySource: "browser-tab", volume: 15, active: false, durationMinutes: 25, diff --git a/sonicflow_app/chrome-extension/popup.html b/sonicflow_app/chrome-extension/popup.html index 14a53a8..758e473 100644 --- a/sonicflow_app/chrome-extension/popup.html +++ b/sonicflow_app/chrome-extension/popup.html @@ -185,6 +185,50 @@ gap: var(--space-sm); } + .overlay-source-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: var(--space-sm) 0; + } + + .source-pill { + display: grid; + gap: 2px; + min-height: 54px; + border: 1px solid var(--neutral-border); + border-radius: var(--radius-md); + padding: 9px 10px; + color: var(--neutral-fg); + background: rgba(255, 255, 255, 0.03); + font: inherit; + text-align: left; + cursor: pointer; + } + + .source-pill.active { + border-color: var(--accent-gold); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-gold), transparent 35%); + } + + .source-pill span, + .source-pill small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .source-pill span { + font-weight: 700; + } + + .source-pill small { + color: var(--neutral-muted); + font-family: var(--font-mono); + font-size: 11px; + } + .stack { display: grid; gap: var(--space-md); @@ -249,6 +293,12 @@
+
+ Overlay Mode +

Layer SonicFlow under supported media while keeping native-only capture paths explicit.

+
+

Checking browser tab for overlay media...

+
diff --git a/sonicflow_app/chrome-extension/popup.js b/sonicflow_app/chrome-extension/popup.js index ba13d7f..d765313 100644 --- a/sonicflow_app/chrome-extension/popup.js +++ b/sonicflow_app/chrome-extension/popup.js @@ -1,5 +1,5 @@ import { extensionApi } from "./browser-polyfill.js"; -import { DEFAULT_SETTINGS, EXAMPLES, MODES } from "./popup-model.js"; +import { DEFAULT_SETTINGS, EXAMPLES, MODES, OVERLAY_SOURCES, resolveOverlayStatus } from "./popup-model.js"; async function queryActiveTabState() { const { tabId } = await extensionApi.runtime.sendMessage({ @@ -96,6 +96,28 @@ function updateHero(settings) { heroDescription.textContent = `${activeMode.description} ${settings.durationMinutes} minute browser-safe layer with ${settings.ambientMix}% ambience and ${settings.pulseDepth}% pulse depth.`; } +function renderOverlaySources(settings, tabState) { + const sourceList = document.querySelector("#overlay-source-list"); + const sourceStatus = document.querySelector("#overlay-source-status"); + const overlayStatus = resolveOverlayStatus(tabState); + + sourceList.replaceChildren(); + for (const source of OVERLAY_SOURCES) { + const item = document.createElement("button"); + item.type = "button"; + item.className = `source-pill${settings.overlaySource === source.id ? " active" : ""}`; + item.dataset.overlaySource = source.id; + item.innerHTML = ` + ${source.label} + ${source.status} + `; + item.title = source.description; + sourceList.append(item); + } + + sourceStatus.textContent = overlayStatus.message; +} + function renderState(settings, tabState) { const dot = document.querySelector("#status-dot"); const statusText = document.querySelector("#status-text"); @@ -112,6 +134,7 @@ function renderState(settings, tabState) { renderModeGrid(settings); renderExamples(settings); + renderOverlaySources(settings, tabState); updateHero(settings); volumeSlider.value = String(settings.volume); volumeValue.textContent = String(settings.volume); @@ -124,19 +147,18 @@ function renderState(settings, tabState) { if (!tabState) { statusText.textContent = "Unavailable"; - pageMessage.textContent = "Open a supported YouTube or SoundCloud tab first."; + pageMessage.textContent = resolveOverlayStatus(null).message; toggleButton.disabled = true; return; } + const overlayStatus = resolveOverlayStatus(tabState); dot.classList.toggle("active", settings.active); statusText.textContent = settings.active ? "Active" : "Off"; - toggleButton.disabled = !tabState.pageHasAudioSource; + toggleButton.disabled = !overlayStatus.ready; toggleButton.classList.toggle("stop", settings.active); toggleButton.textContent = settings.active ? "Stop SonicFlow" : "Start SonicFlow"; - pageMessage.textContent = tabState.pageHasAudioSource - ? "Media element detected. Ready to layer the selected mode." - : "Open YouTube or SoundCloud first."; + pageMessage.textContent = overlayStatus.message; } async function bootstrap() { @@ -177,6 +199,17 @@ async function bootstrap() { renderState(settings, tabState); }); + document.querySelector("#overlay-source-list").addEventListener("click", async (event) => { + const button = event.target.closest("[data-overlay-source]"); + if (!button) { + return; + } + + settings.overlaySource = button.dataset.overlaySource; + await persistSettings(settings); + renderState(settings, tabState); + }); + document.querySelector("#volume-slider").addEventListener("input", async (event) => { settings.volume = Number(event.target.value); await persistSettings(settings); diff --git a/sonicflow_app/chrome-extension/popup.test.js b/sonicflow_app/chrome-extension/popup.test.js index ddd3041..478dc6a 100644 --- a/sonicflow_app/chrome-extension/popup.test.js +++ b/sonicflow_app/chrome-extension/popup.test.js @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { MODES, DEFAULT_SETTINGS, EXAMPLES } from "./popup-model.js"; +import { MODES, DEFAULT_SETTINGS, EXAMPLES, OVERLAY_SOURCES, resolveOverlayStatus } from "./popup-model.js"; const popupMarkup = readFileSync(new URL("./popup.html", import.meta.url), "utf8"); @@ -13,6 +13,7 @@ test("popup model exposes all four beat modes and defaults", () => { ); assert.deepEqual(DEFAULT_SETTINGS, { mode: "focus", + overlaySource: "browser-tab", volume: 15, active: false, durationMinutes: 25, @@ -25,6 +26,21 @@ test("popup model exposes all four beat modes and defaults", () => { ); }); +test("popup model exposes browser overlay source capability", () => { + assert.deepEqual( + OVERLAY_SOURCES.map((source) => source.id), + ["browser-tab", "macos-system", "ios-local", "android-policy-review"] + ); + assert.equal( + resolveOverlayStatus({ pageHasAudioSource: true }).message, + "Overlay ready for this browser tab." + ); + assert.equal( + resolveOverlayStatus({ pageHasAudioSource: false }).message, + "Open YouTube, SoundCloud, or another supported media tab first." + ); +}); + test("popup markup contains the expected control surface anchors", () => { assert.match(popupMarkup, /assets\/leopard_wallpaper\.png/); assert.match(popupMarkup, /id="hero-art"/); @@ -40,4 +56,7 @@ test("popup markup contains the expected control surface anchors", () => { assert.match(popupMarkup, /id="pulse-slider"/); assert.match(popupMarkup, /id="toggle-button"/); assert.match(popupMarkup, /id="page-message"/); + assert.match(popupMarkup, /id="overlay-mode-panel"/); + assert.match(popupMarkup, /id="overlay-source-list"/); + assert.match(popupMarkup, /id="overlay-source-status"/); }); diff --git a/sonicflow_app/ios-app/FlowTones/ContentView.swift b/sonicflow_app/ios-app/FlowTones/ContentView.swift index 2abacc0..184356c 100644 --- a/sonicflow_app/ios-app/FlowTones/ContentView.swift +++ b/sonicflow_app/ios-app/FlowTones/ContentView.swift @@ -40,6 +40,8 @@ struct ContentView: View { sessionPanel(for: screenState) + overlayCapabilityNote(for: screenState) + starterPack selectedSourceLabel(for: screenState) @@ -258,6 +260,28 @@ struct ContentView: View { } } + private func overlayCapabilityNote(for screenState: FlowScreenState) -> some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + Label(screenState.overlayModeTitle, systemImage: "rectangle.on.rectangle") + .font(.headline) + .foregroundStyle(BrandTokens.Neutral.fg) + + Text(screenState.overlayModeStatus) + .font(.footnote) + .foregroundStyle(BrandTokens.Neutral.muted) + .fixedSize(horizontal: false, vertical: true) + + metadataPill("Local file layer", tint: screenState.selectedMode.accentColor) + } + .padding(BrandTokens.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .cinematicGlass( + radius: 24, + tint: screenState.selectedMode.accentColor.opacity(0.07), + stroke: Color.white.opacity(0.12) + ) + } + private var starterPack: some View { VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { Text("Starter Sessions") diff --git a/sonicflow_app/ios-app/FlowTones/FlowScreenState.swift b/sonicflow_app/ios-app/FlowTones/FlowScreenState.swift index 86fadc8..1fcfec9 100644 --- a/sonicflow_app/ios-app/FlowTones/FlowScreenState.swift +++ b/sonicflow_app/ios-app/FlowTones/FlowScreenState.swift @@ -7,6 +7,8 @@ struct FlowScreenState: Equatable { let selectedMode: FlowMode let durationLabel: String let selectedFileLabel: String + let overlayModeTitle: String + let overlayModeStatus: String let ambientMixValue: Double let pulseDepthValue: Double let showsAdvancedControls: Bool @@ -22,6 +24,8 @@ struct FlowScreenState: Equatable { selectedMode = mode durationLabel = "\(settings.durationMinutes) min" selectedFileLabel = selectedFileName ?? "No file selected" + overlayModeTitle = "Overlay Mode" + overlayModeStatus = "iOS can layer SonicFlow under picked files. Spotify and YouTube system overlay are unavailable on iOS." ambientMixValue = settings.ambientMix pulseDepthValue = settings.pulseDepth showsAdvancedControls = true diff --git a/sonicflow_app/ios-app/FlowTonesTests/FlowScreenStateTests.swift b/sonicflow_app/ios-app/FlowTonesTests/FlowScreenStateTests.swift index 57ed976..5aa1926 100644 --- a/sonicflow_app/ios-app/FlowTonesTests/FlowScreenStateTests.swift +++ b/sonicflow_app/ios-app/FlowTonesTests/FlowScreenStateTests.swift @@ -15,6 +15,11 @@ final class FlowScreenStateTests: XCTestCase { XCTAssertEqual(state.transportLabel, "Play") XCTAssertEqual(state.selectedMode, FlowMode.flow) XCTAssertEqual(state.durationLabel, "25 min") + XCTAssertEqual(state.overlayModeTitle, "Overlay Mode") + XCTAssertEqual( + state.overlayModeStatus, + "iOS can layer SonicFlow under picked files. Spotify and YouTube system overlay are unavailable on iOS." + ) XCTAssertTrue(state.showsAdvancedControls) } diff --git a/sonicflow_app/safari-extension/FlowTones/macOS (App)/AudioManager.swift b/sonicflow_app/safari-extension/FlowTones/macOS (App)/AudioManager.swift index 94fed32..dad66e4 100644 --- a/sonicflow_app/safari-extension/FlowTones/macOS (App)/AudioManager.swift +++ b/sonicflow_app/safari-extension/FlowTones/macOS (App)/AudioManager.swift @@ -225,7 +225,7 @@ class AudioManager: ObservableObject { var selectedFileLabel: String { switch selectedSource { case .system: - return "System layer" + return "Overlay layer" case .file: return "File layer" } diff --git a/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift b/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift index 110b01a..3c64c95 100644 --- a/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift +++ b/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift @@ -147,7 +147,7 @@ struct FlowTonesPopoverView: View { .tint(audioManager.currentMode.accentColor) Picker("Source", selection: $audioManager.selectedSource) { - Text("System").tag(AudioSource.system) + Text("Overlay").tag(AudioSource.system) Text("File").tag(AudioSource.file) } .labelsHidden() @@ -309,6 +309,10 @@ struct FlowTonesPopoverView: View { private var sourceCluster: some View { VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + Text("Overlay Mode") + .font(.headline) + .foregroundStyle(BrandTokens.Neutral.fg) + sourceSection sourceStatus @@ -326,7 +330,7 @@ struct FlowTonesPopoverView: View { switch audioManager.selectedSource { case .system: VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - Text(audioManager.canCaptureSystemAudio ? "System capture ready" : "System capture needs macOS 14.2") + Text(audioManager.canCaptureSystemAudio ? "macOS system overlay ready" : "Overlay capture needs macOS 14.2") .font(.caption.weight(.semibold)) .foregroundStyle( audioManager.canCaptureSystemAudio @@ -336,7 +340,7 @@ struct FlowTonesPopoverView: View { Text("Permission: \(audioManager.systemAudioPermissionStatus)") .font(.caption) .foregroundStyle(BrandTokens.Neutral.muted) - Button("Start Capture") { + Button("Start Overlay Capture") { audioManager.startSystemAudioCapture() } .buttonStyle(.bordered)