Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ class FlowTonesViewModel @Inject constructor(
private val mutableSelectedFile = MutableStateFlow<String?>(null)
val selectedFile: StateFlow<String?> = mutableSelectedFile.asStateFlow()

private val mutableOverlayModeStatus = MutableStateFlow(
"Overlay Mode: Android external app capture requires policy review; local sessions remain available."
)
val overlayModeStatus: StateFlow<String> = mutableOverlayModeStatus.asStateFlow()

private val mutableFilePickerEvents = MutableSharedFlow<Unit>()
val filePickerEvents: SharedFlow<Unit> = mutableFilePickerEvents.asSharedFlow()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions sonicflow_app/chrome-extension/popup-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions sonicflow_app/chrome-extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -249,6 +293,12 @@
<div class="panel">
<div class="grid" id="mode-grid"></div>
</div>
<div class="panel" id="overlay-mode-panel">
<strong>Overlay Mode</strong>
<p>Layer SonicFlow under supported media while keeping native-only capture paths explicit.</p>
<div class="overlay-source-list" id="overlay-source-list"></div>
<p id="overlay-source-status">Checking browser tab for overlay media...</p>
</div>
<div class="panel stack">
<div class="slider-row">
<div class="slider-meta">
Expand Down
45 changes: 39 additions & 6 deletions sonicflow_app/chrome-extension/popup.js
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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 = `
<span>${source.label}</span>
<small>${source.status}</small>
`;
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");
Expand All @@ -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);
Expand All @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 20 additions & 1 deletion sonicflow_app/chrome-extension/popup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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,
Expand All @@ -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"/);
Expand All @@ -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"/);
});
24 changes: 24 additions & 0 deletions sonicflow_app/ios-app/FlowTones/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ struct ContentView: View {

sessionPanel(for: screenState)

overlayCapabilityNote(for: screenState)

starterPack

selectedSourceLabel(for: screenState)
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions sonicflow_app/ios-app/FlowTones/FlowScreenState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading
Loading