Repository: web-scrobbler/web-scrobbler
Pull request #3728 (2023-05-16 09:22:21)

Add compatibility with multiple player selectors
Caused some bugginess with the new youtube connector. Now fetches the first selector that matches.


Patch:
diff --git a/scripts/util.ts b/scripts/util.ts
index 765d62f7..e7b4fb0c 100644
--- a/scripts/util.ts
+++ b/scripts/util.ts
@@ -1,3 +1,6 @@
+import { resolve } from 'path';
+import { normalizePath } from 'vite';
+
 /**
  * Folder names corresponding to build targets
  */
@@ -37,5 +40,14 @@ export function getBrowser() {
 	return browserName;
 }
 
+/**
+ * Resolve vite path in a cross-platform way
+ *
+ * @param paths - paths to resolve
+ */
+export function resolvePath(...paths: string[]) {
+	return normalizePath(resolve(...paths));
+}
+
 export const releaseTarget = process.argv.at(-1);
 export const releaseType = process.argv.at(-2);
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index efc4f7d4..34dcbe8f 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -511,6 +511,14 @@
         "message": "You cannot edit this track",
         "description": "Title of edit button"
     },
+    "infoEditTitleShort": {
+        "message": "Edit",
+        "description": "Title of edit button, should be short for mobile button label"
+    },
+    "infoEditUnableTitleShort": {
+        "message": "Can't Edit",
+        "description": "Title of edit button, should be short for mobile button label"
+    },
     "infoSwapTitle": {
         "message": "Swap title and artist",
         "description": "Title of swap button"
@@ -519,10 +527,22 @@
         "message": "You cannot swap empty track info",
         "description": "Title of swap button"
     },
+    "infoSwapTitleShort": {
+        "message": "Swap title/artist",
+        "description": "Title of swap button, should be short for mobile button label"
+    },
+    "infoSwapUnableTitleShort": {
+        "message": "Can't swap info",
+        "description": "Title of swap button, should be short for mobile button label"
+    },
     "infoRegexTitle": {
         "message": "Regex/Bulk edit mode",
         "description": "Title of regex/bulk edit button"
     },
+    "infoRegexTitleShort": {
+        "message": "Regex/Bulk edit",
+        "description": "Title of regex/bulk edit button, should be short for mobile button label"
+    },
     "infoEditedWarning": {
         "message": "This track has been edited. Regex edits are not applied to edited tracks, and the preview you see here may be inaccurate.",
         "description": "Warning message to user if bulk editing on a previously edited track"
@@ -535,6 +555,14 @@
         "message": "You cannot submit empty track info",
         "description": "Title of submit button"
     },
+    "infoSubmitTitleShort": {
+        "message": "Submit",
+        "description": "Title of submit button, should be short for mobile button label"
+    },
+    "infoSubmitUnableTitleShort": {
+        "message": "Can't submit",
+        "description": "Title of submit button, should be short for mobile button label"
+    },
     "infoRevertTitle": {
         "message": "Revert changes",
         "description": "Title of revert button"
@@ -543,6 +571,14 @@
         "message": "You cannot revert this track",
         "description": "Title of revert button"
     },
+    "infoRevertTitleShort": {
+      "message": "Revert",
+      "description": "Title of revert button, should be short for mobile button label"
+    },
+    "infoRevertUnableTitleShort": {
+      "message": "Can't Revert",
+      "description": "Title of revert button, should be short for mobile button label"
+    },
     "infoSkipTitle": {
         "message": "Don't scrobble current track",
         "description": "Title of skip button"
@@ -555,6 +591,18 @@
         "message": "You skipped this track",
         "description": "Title of unskip button"
     },
+    "infoSkipTitleShort": {
+      "message": "Skip",
+      "description": "Title of skip button, should be short for mobile button label"
+    },
+    "infoSkipUnableTitleShort": {
+      "message": "Can't Skip",
+      "description": "Title of skip button, should be short for mobile button label"
+    },
+    "infoSkippedTitleShort": {
+      "message": "Skipped",
+      "description": "Title of unskip button, should be short for mobile button label"
+    },
     "infoOpenAlbumArt": {
         "message": "Open album art in new tab",
         "description": "Title of album image"
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index a19fef68..20bad3b5 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -17,6 +17,9 @@ import {
 	getCurrentTab,
 	setState,
 	unlockState,
+	enableConnector,
+	disableConnector,
+	disableUntilClosed,
 } from './util';
 import { ControllerModeStr } from '@/core/object/controller/controller';
 import { CloneableSong } from '@/core/object/song';
@@ -331,24 +334,15 @@ async function contextMenuHandler(info: browser.Menus.OnClickData) {
 
 	switch (info.menuItemId) {
 		case contextMenus.ENABLE_CONNECTOR: {
-			sendBackgroundMessage(tab.tabId, {
-				type: 'setConnectorState',
-				payload: true,
-			});
+			enableConnector(tab.tabId);
 			break;
 		}
 		case contextMenus.DISABLE_CONNECTOR: {
-			sendBackgroundMessage(tab.tabId, {
-				type: 'setConnectorState',
-				payload: false,
-			});
+			disableConnector(tab.tabId);
 			break;
 		}
 		case contextMenus.DISABLE_UNTIL_CLOSED: {
-			sendBackgroundMessage(tab.tabId, {
-				type: 'disableConnectorUntilTabIsClosed',
-				payload: undefined,
-			});
+			disableUntilClosed(tab.tabId);
 			break;
 		}
 	}
diff --git a/src/core/background/util.ts b/src/core/background/util.ts
index 3675b934..1d91a273 100644
--- a/src/core/background/util.ts
+++ b/src/core/background/util.ts
@@ -5,6 +5,7 @@ import * as ControllerMode from '@/core/object/controller/controller-mode';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import { controllerModePriority } from '@/core/object/controller/controller';
 import { performUpdateAction } from './action';
+import { sendBackgroundMessage } from '@/util/communication';
 
 const state = BrowserStorage.getStorage(BrowserStorage.STATE_MANAGEMENT);
 
@@ -134,3 +135,39 @@ export const DEFAULT_STATE: StateManagement = {
 	activeTabs: [],
 	browserPreferredTheme: 'light',
 };
+
+/**
+ * Disables connector of a tab
+ *
+ * @param tabId - tab id of tab to disable connector of
+ */
+export function disableConnector(tabId: number) {
+	sendBackgroundMessage(tabId, {
+		type: 'setConnectorState',
+		payload: false,
+	});
+}
+
+/**
+ * Enables connector of a tab
+ *
+ * @param tabId - tab id of tab to enable connector of
+ */
+export function enableConnector(tabId: number) {
+	sendBackgroundMessage(tabId, {
+		type: 'setConnectorState',
+		payload: true,
+	});
+}
+
+/**
+ * Disables connector of a tab until the tab is closed
+ *
+ * @param tabId - tab id of tab to disable connector of
+ */
+export function disableUntilClosed(tabId: number) {
+	sendBackgroundMessage(tabId, {
+		type: 'disableConnectorUntilTabIsClosed',
+		payload: undefined,
+	});
+}
diff --git a/src/core/content/starter.ts b/src/core/content/starter.ts
index 9ea175a0..02216768 100644
--- a/src/core/content/starter.ts
+++ b/src/core/content/starter.ts
@@ -136,5 +136,19 @@ function setupSecondObserver() {
  * @returns the element to be observed
  */
 function retrieveObserveTarget(): Element | null {
-	return document.querySelector(Connector.playerSelector?.toString() ?? '');
+	if (Connector.playerSelector === null) {
+		return null;
+	}
+
+	if (typeof Connector.playerSelector === 'object') {
+		for (const selector of Connector.playerSelector) {
+			const element = document.querySelector(selector);
+			if (element !== null) {
+				return element;
+			}
+		}
+		return null;
+	}
+
+	return document.querySelector(Connector.playerSelector);
 }
diff --git a/src/ui/components/context-menu/context-menu.module.scss b/src/ui/components/context-menu/context-menu.module.scss
index 02d36719..fdd6e4b2 100644
--- a/src/ui/components/context-menu/context-menu.module.scss
+++ b/src/ui/components/context-menu/context-menu.module.scss
@@ -1,6 +1,5 @@
 .contextMenu {
 	background-color: var(--last-fm-bg);
-	bottom: 0;
 	color: var(--text);
 	display: flex;
 
@@ -9,6 +8,13 @@
 		padding: 0.5em 0;
 		position: relative;
 
+		@supports (-webkit-touch-callout: none) {
+			&.activeDialogButton {
+				background-color: var(--always-last-fm-hover);
+				color: var(--always-light);
+			}
+		}
+
 		&.activeDialogButton::after {
 			animation: 0.1s ease-in-out forwards showTip;
 			border-left: 20px solid transparent;
@@ -40,7 +46,18 @@
 	position: absolute;
 	transform: translateY(calc(-100%)) scale(0);
 	transform-origin: bottom center;
-	z-index: 0;
+
+	&.leftDialog {
+		margin-left: 20px;
+		margin-right: auto;
+		transform-origin: bottom left;
+	}
+
+	&.rightDialog {
+		margin-left: auto;
+		margin-right: 20px;
+		transform-origin: bottom right;
+	}
 
 
 	// stylelint-disable-next-line no-duplicate-selectors
@@ -70,6 +87,8 @@
 
 .contextMenu,
 .contextMenuDialog {
+	z-index: 1000000;
+
 	// stylelint-disable-next-line no-duplicate-selectors
 	& {
 		/* avoid flickering on ios */
@@ -93,9 +112,11 @@
 			margin: 0 auto;
 		}
 
-		&:hover {
-			background-color: var(--always-last-fm-hover);
-			color: var(--always-light);
+		@supports not (-webkit-touch-callout: none) {
+			&:hover {
+				background-color: var(--always-last-fm-hover);
+				color: var(--always-light);
+			}
 		}
 	}
 }
diff --git a/src/ui/components/context-menu/context-menu.tsx b/src/ui/components/context-menu/context-menu.tsx
index 67aa2db3..42c37e7c 100644
--- a/src/ui/components/context-menu/context-menu.tsx
+++ b/src/ui/components/context-menu/context-menu.tsx
@@ -6,11 +6,29 @@ import {
 	itemIsSingular,
 	triggerNavigationButton,
 } from '@/ui/options/components/navigator';
-import { For, Setter, Show, createSignal, onMount } from 'solid-js';
+import {
+	Accessor,
+	For,
+	Setter,
+	Show,
+	createMemo,
+	createSignal,
+	onMount,
+} from 'solid-js';
 import styles from './context-menu.module.scss';
 import { t } from '@/util/i18n';
 
 function closeDialogs(e: MouseEvent) {
+	if (!(e.target instanceof Element)) return;
+
+	const dialogButton = e.target.closest(`.${styles.contextMenuItem}`);
+	const dialog = dialogButton?.nextElementSibling;
+
+	let currentlyOpen = true;
+	if (dialog instanceof HTMLDialogElement) {
+		currentlyOpen = dialog.open;
+	}
+
 	const dialogs = document.querySelectorAll('dialog');
 	dialogs.forEach((dialog) => {
 		dialog.close();
@@ -22,12 +40,7 @@ function closeDialogs(e: MouseEvent) {
 		dialogButton.classList.remove(styles.activeDialogButton);
 	});
 
-	if (!(e.target instanceof Element)) return;
-
-	const dialogButton = e.target.closest(`.${styles.contextMenuItem}`);
-	const dialog = dialogButton?.nextElementSibling;
-
-	if (!(dialog instanceof HTMLDialogElement)) return;
+	if (!(dialog instanceof HTMLDialogElement) || currentlyOpen) return;
 
 	dialog.show();
 	dialogButton?.classList.add(styles.activeDialogButton);
@@ -35,7 +48,7 @@ function closeDialogs(e: MouseEvent) {
 
 export default function ContextMenu(props: {
 	items: Navigator;
-	setActiveSetting: Setter<NavigatorNavigationButton>;
+	setActiveSetting?: Setter<NavigatorNavigationButton>;
 }) {
 	onMount(() => {
 		window.addEventListener('click', closeDialogs);
@@ -54,12 +67,14 @@ export default function ContextMenu(props: {
 
 function ContextMenuItems(props: {
 	items: Navigator;
-	setActiveSetting: Setter<NavigatorNavigationButton>;
+	setActiveSetting?: Setter<NavigatorNavigationButton>;
 }) {
 	return (
 		<For each={props.items}>
-			{(item) => (
+			{(item, index) => (
 				<ContextMenuItem
+					index={index}
+					length={props.items.length}
 					item={item}
 					setActiveSetting={props.setActiveSetting}
 				/>
@@ -69,10 +84,25 @@ function ContextMenuItems(props: {
 }
 
 function ContextMenuItem(props: {
+	index: Accessor<number>;
+	length: number;
 	item: NavigatorButtonGroup | NavigatorButton;
-	setActiveSetting: Setter<NavigatorNavigationButton>;
+	setActiveSetting?: Setter<NavigatorNavigationButton>;
 }) {
 	const [mounted, setMounted] = createSignal(false);
+	const locationClass = createMemo(() => {
+		if (props.length === 1) {
+			return styles.centerDialog;
+		}
+		if (props.index() === 0) {
+			return styles.leftDialog;
+		}
+		if (props.index() === props.length - 1) {
+			return styles.rightDialog;
+		}
+		return styles.centerDialog;
+	});
+
 	// hacky way to ensure animation doesnt play on load. If its not loaded it just fails to display an animation, no big deal.
 	onMount(() => {
 		setTimeout(() => {
@@ -83,16 +113,24 @@ function ContextMenuItem(props: {
 		<>
 			<button
 				class={styles.contextMenuItem}
-				onClick={() =>
-					triggerNavigationButton(props.item, props.setActiveSetting)
-				}
+				onClick={() => {
+					if (props.setActiveSetting && 'element' in props.item) {
+						triggerNavigationButton(
+							props.item,
+							props.setActiveSetting
+						);
+					}
+					if ('action' in props.item) {
+						props.item.action();
+					}
+				}}
 			>
 				<props.item.icon />
 				<label>{t(props.item.namei18n)}</label>
 			</button>
 			<Show when={!itemIsSingular(props.item)}>
 				<dialog
-					class={`${styles.contextMenuDialog} ${
+					class={`${styles.contextMenuDialog} ${locationClass()} ${
 						mounted() && `${styles.mountedAnimation}`
 					}`}
 				>
diff --git a/src/ui/components/util.tsx b/src/ui/components/util.tsx
new file mode 100644
index 00000000..e5251ed7
--- /dev/null
+++ b/src/ui/components/util.tsx
@@ -0,0 +1,13 @@
+export function Squircle(props: { id: string }) {
+	return (
+		<svg width="0" height="0">
+			<clipPath id={props.id} clipPathUnits="objectBoundingBox">
+				<path d="M 0, 0.5 C 0, 0.03 0.03, 0 0.5, 0 S 1, 0.03 1, 0.5 0.97, 1 0.5, 1 0, 0.97 0, 0.5" />
+			</clipPath>
+		</svg>
+	);
+}
+
+export function isIos() {
+	return CSS.supports('-webkit-touch-callout', 'none');
+}
diff --git a/src/ui/options/components/edit-options/edited-tracks.tsx b/src/ui/options/components/edit-options/edited-tracks.tsx
index 11b59080..66ad7e9c 100644
--- a/src/ui/options/components/edit-options/edited-tracks.tsx
+++ b/src/ui/options/components/edit-options/edited-tracks.tsx
@@ -75,7 +75,8 @@ function TrackInfo(props: {
 		<li class={styles.deleteListing}>
 			<button
 				class={styles.deleteEditButton}
-				onClick={() => {
+				onClick={(event) => {
+					event.stopPropagation();
 					props.mutate((e) => {
 						if (!e) return e;
 						delete e[props.key];
diff --git a/src/ui/options/components/edit-options/regex-edits.tsx b/src/ui/options/components/edit-options/regex-edits.tsx
index 881ce1b7..a3a5e117 100644
--- a/src/ui/options/components/edit-options/regex-edits.tsx
+++ b/src/ui/options/components/edit-options/regex-edits.tsx
@@ -73,7 +73,8 @@ function EditInfo(props: {
 		<li class={styles.deleteListing}>
 			<button
 				class={styles.deleteEditButton}
-				onClick={() => {
+				onClick={(event) => {
+					event.stopPropagation();
 					props.mutate((e) => {
 						if (!e) return e;
 						const o = e.filter((_, i) => i !== props.index);
diff --git a/src/ui/options/components/navigator.ts b/src/ui/options/components/navigator.ts
index 83675a8c..bf6983ee 100644
--- a/src/ui/options/components/navigator.ts
+++ b/src/ui/options/components/navigator.ts
@@ -12,9 +12,21 @@ import FAQ from '@/ui/options/components/faq';
 import ContactComponent from '@/ui/options/components/contact';
 import OptionsComponent from '@/ui/options/components/options/options';
 import Accounts from '@/ui/options/components/accounts';
+import ToggleOn from '@suid/icons-material/ToggleOnOutlined';
+import ToggleOff from '@suid/icons-material/ToggleOffOutlined';
+import Timer from '@suid/icons-material/TimerOutlined';
 import ConnectorOverrideOptions from '@/ui/options/components/connector-override';
 import EditOptions from '@/ui/options/components/edit-options/edit-options';
 import Favorite from '@suid/icons-material/FavoriteOutlined';
+import browser from 'webextension-polyfill';
+import {
+	disableConnector,
+	disableUntilClosed,
+	enableConnector,
+	getCurrentTab,
+} from '@/core/background/util';
+import { getConnectorByUrl } from '@/util/util-connector';
+import * as ControllerMode from '@/core/object/controller/controller-mode';
 
 /**
  * Type indicating possible states for modal
@@ -96,6 +108,68 @@ export const settings: Navigator = [
 	{ namei18n: 'showSomeLoveTitle', icon: Favorite, element: ShowSomeLove },
 ];
 
+async function getToggleNavigators(): Promise<NavigatorActionButton[]> {
+	const tab = await getCurrentTab();
+
+	if (tab.mode === ControllerMode.Unsupported) {
+		return [];
+	}
+
+	if (tab.mode === ControllerMode.Disabled) {
+		return [
+			{
+				namei18n: 'menuEnableConnector',
+				icon: ToggleOn,
+				action: () => {
+					enableConnector(tab.tabId);
+				},
+			},
+		];
+	}
+
+	return [
+		{
+			namei18n: 'menuDisableConnector',
+			icon: ToggleOff,
+			action: () => {
+				disableConnector(tab.tabId);
+			},
+		},
+		{
+			namei18n: 'menuDisableUntilTabClosed',
+			icon: Timer,
+			action: () => {
+				disableUntilClosed(tab.tabId);
+			},
+		},
+	];
+}
+
+export async function getMobileNavigatorGroup(): Promise<NavigatorButtonGroup> {
+	const group: NavigatorButtonGroup = {
+		namei18n: 'optionsOptions',
+		icon: Settings,
+		group: [
+			{
+				namei18n: 'optionsOptions',
+				icon: Settings,
+				action: () => {
+					browser.tabs.create({
+						url: browser.runtime.getURL(
+							'src/ui/options/index.html'
+						),
+					});
+				},
+			},
+		],
+	};
+
+	const toggleNavigators = await getToggleNavigators();
+	group.group.push(...toggleNavigators);
+
+	return group;
+}
+
 export function triggerNavigationButton(
 	button: NavigatorButton | NavigatorButtonGroup,
 	setActiveSetting: Setter<NavigatorNavigationButton>
@@ -120,5 +194,5 @@ export function triggerNavigationButton(
 export function itemIsSingular(
 	item: NavigatorButton | NavigatorButtonGroup
 ): item is NavigatorButton {
-	return 'element' in item;
+	return !('group' in item);
 }
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index 19aeb1be..1a860d07 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -41,12 +41,16 @@ function Options() {
 
 	const onclick = (e: MouseEvent) => {
 		const target = e.target;
-		if (!(target instanceof HTMLElement)) return;
+		if (!(target instanceof HTMLElement)) {
+			return;
+		}
 		if (
 			target.closest(`.${styles.modal}`) &&
 			!target.classList.contains(styles.modal)
-		)
+		) {
+			e.stopPropagation();
 			return;
+		}
 		modal?.close();
 	};
 	document.addEventListener('click', onclick);
diff --git a/src/ui/options/settings.module.scss b/src/ui/options/settings.module.scss
index 07e0833d..dc2bec2e 100644
--- a/src/ui/options/settings.module.scss
+++ b/src/ui/options/settings.module.scss
@@ -95,12 +95,23 @@ a {
 	position: relative;
 	width: 80vw;
 
+	@supports (-webkit-touch-callout: none) {
+		border-radius: 0;
+		// stylelint-disable-next-line unit-no-unknown
+		height: 100dvh;
+		margin: 0;
+		// stylelint-disable-next-line unit-no-unknown
+		max-height: 100dvh;
+		max-width: 100vw;
+		width: 100vw;
+	}
+
 	.modalContent {
 		box-sizing: border-box;
-		height: 80vh;
+		height: 100%;
 		overflow: auto;
 		padding: 1em;
-		width: 80vw;
+		width: 100%;
 	}
 
 	.modalClose {
@@ -113,6 +124,10 @@ a {
 		right: 0;
 		top: 0;
 
+		@supports (-webkit-touch-callout: none) {
+			padding: 0.75em 1em 0.5em;
+		}
+
 		&:hover {
 			background-color: var(--always-last-fm-hover);
 		}
diff --git a/src/ui/popup/edit.tsx b/src/ui/popup/edit.tsx
index 128aa40f..de96e930 100644
--- a/src/ui/popup/edit.tsx
+++ b/src/ui/popup/edit.tsx
@@ -1,9 +1,14 @@
 import { t } from '@/util/i18n';
 import styles from './popup.module.scss';
 import {
+	Accessor,
 	Match,
 	Resource,
+	Setter,
+	Show,
 	Switch,
+	createMemo,
+	createResource,
 	createSignal,
 	onCleanup,
 	onMount,
@@ -16,7 +21,13 @@ import Code from '@suid/icons-material/CodeOutlined';
 import PublishedWithChanges from '@suid/icons-material/PublishedWithChangesOutlined';
 import { sendBackgroundMessage } from '@/util/communication';
 import savedEdits from '@/core/storage/saved-edits';
-import Regex from './regex';
+import Regex, { RegexEditContextMenu } from './regex';
+import { Squircle, isIos } from '../components/util';
+import {
+	Navigator,
+	getMobileNavigatorGroup,
+} from '../options/components/navigator';
+import ContextMenu from '../components/context-menu/context-menu';
 
 /**
  * Component that allows the user to edit the currently playing track
@@ -74,127 +85,212 @@ export default function Edit(props: { tab: Resource<ManagerTab> }) {
 	});
 
 	return (
-		<div class={styles.nowPlayingPopup} ref={nowplaying}>
-			<Switch>
-				<Match when={isRegex()}>
-					<Regex clonedSong={clonedSong} tab={tab} />
-				</Match>
-				<Match when={!isRegex()}>
-					<a
-						class={styles.coverArtWrapper}
-						href={
-							clonedSong.getTrackArt() ??
-							browser.runtime.getURL('img/cover_art_default.png')
-						}
-						target="_blank"
-						rel="noopener noreferrer"
-						title={t('infoOpenAlbumArt')}
-					>
-						<img
-							class={styles.coverArt}
-							src={
+		<>
+			<Show when={isIos()}>
+				<Switch>
+					<Match when={isRegex()}>
+						<RegexEditContextMenu tab={tab} />
+					</Match>
+					<Match when={!isRegex()}>
+						<EditContextMenu
+							tab={tab}
+							clonedSong={clonedSong}
+							setIsRegex={setIsRegex}
+							track={track}
+							artist={artist}
+							album={album}
+							albumArtist={albumArtist}
+						/>
+					</Match>
+				</Switch>
+			</Show>
+			<div class={styles.nowPlayingPopup} ref={nowplaying}>
+				<Switch>
+					<Match when={isRegex()}>
+						<Regex clonedSong={clonedSong} tab={tab} />
+					</Match>
+					<Match when={!isRegex()}>
+						<a
+							class={styles.coverArtWrapper}
+							href={
 								clonedSong.getTrackArt() ??
 								browser.runtime.getURL(
 									'img/cover_art_default.png'
 								)
 							}
-						/>
-					</a>
-					<div class={styles.songDetails}>
-						<input
-							class={styles.editField}
-							type="text"
-							value={clonedSong.getTrack() ?? ''}
-							title={t('infoTrackPlaceholder')}
-							placeholder={t('infoTrackPlaceholder')}
-							onInput={(e) => {
-								setTrack(e.currentTarget.value);
-							}}
-						/>
-						<input
-							class={styles.editField}
-							type="text"
-							value={clonedSong.getArtist() ?? ''}
-							title={t('infoArtistPlaceholder')}
-							placeholder={t('infoArtistPlaceholder')}
-							onInput={(e) => {
-								setArtist(e.currentTarget.value);
-							}}
-						/>
-						<input
-							class={styles.editField}
-							type="text"
-							value={clonedSong.getAlbum() ?? ''}
-							title={t('infoAlbumPlaceholder')}
-							placeholder={t('infoAlbumPlaceholder')}
-							onInput={(e) => {
-								setAlbum(e.currentTarget.value);
-							}}
-						/>
-						<input
-							class={styles.editField}
-							type="text"
-							value={clonedSong.getAlbumArtist() ?? ''}
-							title={t('infoAlbumArtistPlaceholder')}
-							placeholder={t('infoAlbumArtistPlaceholder')}
-							onInput={(e) => {
-								setAlbumArtist(e.currentTarget.value);
-							}}
-						/>
-						<div class={styles.controlButtons}>
-							<button
-								class={styles.controlButton}
-								disabled={!track() || !artist()}
-								title={
-									!track() || !artist()
-										? t('infoSubmitUnableTitle')
-										: t('infoSubmitTitle')
+							target="_blank"
+							rel="noopener noreferrer"
+							title={t('infoOpenAlbumArt')}
+						>
+							<img
+								class={styles.coverArt}
+								src={
+									clonedSong.getTrackArt() ??
+									browser.runtime.getURL(
+										'img/cover_art_default.png'
+									)
 								}
-								onClick={() => {
-									saveEdit(tab, clonedSong, {
-										artist: artist(),
-										track: track(),
-										album: album() || null,
-										albumArtist: albumArtist() || null,
-									});
+							/>
+							<Squircle id="coverArtClip" />
+						</a>
+						<div class={styles.songDetails}>
+							<input
+								class={styles.editField}
+								type="text"
+								value={clonedSong.getTrack() ?? ''}
+								title={t('infoTrackPlaceholder')}
+								placeholder={t('infoTrackPlaceholder')}
+								onInput={(e) => {
+									setTrack(e.currentTarget.value);
 								}}
-							>
-								<Check />
-							</button>
-							<button
-								class={styles.controlButton}
-								disabled={!track() || !artist()}
-								title={
-									!track() || !artist()
-										? t('infoSwapUnableTitle')
-										: t('infoSwapTitle')
-								}
-								onClick={() => {
-									saveEdit(tab, clonedSong, {
-										artist: track(),
-										track: artist(),
-										album: album() || null,
-										albumArtist: albumArtist() || null,
-									});
+							/>
+							<input
+								class={styles.editField}
+								type="text"
+								value={clonedSong.getArtist() ?? ''}
+								title={t('infoArtistPlaceholder')}
+								placeholder={t('infoArtistPlaceholder')}
+								onInput={(e) => {
+									setArtist(e.currentTarget.value);
 								}}
-							>
-								<PublishedWithChanges />
-							</button>
-							<button
-								class={styles.controlButton}
-								title={t('infoRegexTitle')}
-								onClick={() => setIsRegex(true)}
-							>
-								<Code />
-							</button>
+							/>
+							<input
+								class={styles.editField}
+								type="text"
+								value={clonedSong.getAlbum() ?? ''}
+								title={t('infoAlbumPlaceholder')}
+								placeholder={t('infoAlbumPlaceholder')}
+								onInput={(e) => {
+									setAlbum(e.currentTarget.value);
+								}}
+							/>
+							<input
+								class={styles.editField}
+								type="text"
+								value={clonedSong.getAlbumArtist() ?? ''}
+								title={t('infoAlbumArtistPlaceholder')}
+								placeholder={t('infoAlbumArtistPlaceholder')}
+								onInput={(e) => {
+									setAlbumArtist(e.currentTarget.value);
+								}}
+							/>
+							<Show when={!isIos()}>
+								<div class={styles.controlButtons}>
+									<button
+										class={styles.controlButton}
+										disabled={!track() || !artist()}
+										title={
+											!track() || !artist()
+												? t('infoSubmitUnableTitle')
+												: t('infoSubmitTitle')
+										}
+										onClick={() => {
+											saveEdit(tab, clonedSong, {
+												artist: artist(),
+												track: track(),
+												album: album() || null,
+												albumArtist:
+													albumArtist() || null,
+											});
+										}}
+									>
+										<Check />
+									</button>
+									<button
+										class={styles.controlButton}
+										disabled={!track() || !artist()}
+										title={
+											!track() || !artist()
+												? t('infoSwapUnableTitle')
+												: t('infoSwapTitle')
+										}
+										onClick={() => {
+											saveEdit(tab, clonedSong, {
+												artist: track(),
+												track: artist(),
+												album: album() || null,
+												albumArtist:
+													albumArtist() || null,
+											});
+										}}
+									>
+										<PublishedWithChanges />
+									</button>
+									<button
+										class={styles.controlButton}
+										title={t('infoRegexTitle')}
+										onClick={() => setIsRegex(true)}
+									>
+										<Code />
+									</button>
+								</div>
+							</Show>
 						</div>
-					</div>
-				</Match>
-			</Switch>
-		</div>
+					</Match>
+				</Switch>
+			</div>
+		</>
 	);
 }
 
+function EditContextMenu(props: {
+	tab: Resource<ManagerTab>;
+	clonedSong: ClonedSong;
+	setIsRegex: Setter<boolean>;
+	track: Accessor<string>;
+	artist: Accessor<string>;
+	album: Accessor<string>;
+	albumArtist: Accessor<string>;
+}) {
+	const [navigatorResource] = createResource(getMobileNavigatorGroup);
+	const items = createMemo(() => {
+		const items: Navigator = [
+			{
+				namei18n:
+					!props.track() || !props.artist()
+						? 'infoSubmitUnableTitleShort'
+						: 'infoSubmitTitleShort',
+				icon: Check,
+				action: () =>
+					saveEdit(props.tab, props.clonedSong, {
+						artist: props.artist(),
+						track: props.track(),
+						album: props.album() || null,
+						albumArtist: props.albumArtist() || null,
+					}),
+			},
+			{
+				namei18n:
+					!props.track() || !props.artist()
+						? 'infoSwapUnableTitleShort'
+						: 'infoSwapTitleShort',
+				icon: PublishedWithChanges,
+				action: () =>
+					saveEdit(props.tab, props.clonedSong, {
+						artist: props.track(),
+						track: props.artist(),
+						album: props.album() || null,
+						albumArtist: props.albumArtist() || null,
+					}),
+			},
+			{
+				namei18n: 'infoRegexTitleShort',
+				icon: Code,
+				action: () => props.setIsRegex(true),
+			},
+		];
+		if (!navigatorResource.loading) {
+			const navigatorGroup = navigatorResource();
+			if (navigatorGroup) {
+				items.push(navigatorGroup);
+			}
+		}
+		return items;
+	});
+
+	return <ContextMenu items={items()} />;
+}
+
 /**
  * Save edit to storage
  * @param tab - tab data of the tab whose data is being edited
diff --git a/src/ui/popup/index.html b/src/ui/popup/index.html
index fed80a17..97ca6a8e 100644
--- a/src/ui/popup/index.html
+++ b/src/ui/popup/index.html
@@ -8,6 +8,5 @@
 	<script src="main.tsx" type="module"></script>
 </head>
 <body>
-	<div id="root"></div>
 </body>
 </html>
diff --git a/src/ui/popup/main.tsx b/src/ui/popup/main.tsx
index 284cbd32..5de79ea7 100644
--- a/src/ui/popup/main.tsx
+++ b/src/ui/popup/main.tsx
@@ -4,7 +4,15 @@ import Unsupported from './unsupported';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
 import { initializeThemes } from '@/theme/themes';
 import '@/theme/themes.scss';
-import { Match, Switch, createResource, Show } from 'solid-js';
+import {
+	Match,
+	Switch,
+	createResource,
+	Show,
+	createMemo,
+	Accessor,
+	createEffect,
+} from 'solid-js';
 import { popupListener, setupPopupListeners } from '@/util/communication';
 import Base from './base';
 import { getCurrentTab } from '@/core/background/util';
@@ -16,6 +24,12 @@ import Settings from '@suid/icons-material/SettingsOutlined';
 import browser from 'webextension-polyfill';
 import styles from './popup.module.scss';
 import { t } from '@/util/i18n';
+import { isIos } from '../components/util';
+import ContextMenu from '../components/context-menu/context-menu';
+import {
+	Navigator,
+	getMobileNavigatorGroup,
+} from '../options/components/navigator';
 
 /**
  * List of modes that have a settings button in the popup content, dont show supplementary settings icon.
@@ -27,11 +41,40 @@ const settingModes = [
 	ControllerMode.Loading,
 ];
 
+/**
+ * List of modes that already have context menu handling in the popup content, dont show supplementary context menu on ios.
+ */
+const contextMenuModes = [
+	ControllerMode.Playing,
+	ControllerMode.Skipped,
+	ControllerMode.Scrobbled,
+	ControllerMode.Loading,
+	ControllerMode.Unknown,
+];
+
 /**
  * Component that determines what popup to show, and then shows it
  */
 function Popup() {
 	const [tab, setTab] = createResource(getCurrentTab);
+	const [navigatorResource, { refetch }] = createResource(
+		getMobileNavigatorGroup
+	);
+
+	createEffect(() => {
+		tab(); // does nothing, but causes the effect to re-run when the tab changes
+		refetch();
+	});
+
+	const items: Accessor<Navigator> = createMemo(() => {
+		if (!navigatorResource.loading) {
+			const navigator = navigatorResource();
+			if (navigator) {
+				return [navigator];
+			}
+		}
+		return [];
+	});
 
 	setupPopupListeners(
 		popupListener({
@@ -44,6 +87,11 @@ function Popup() {
 
 	return (
 		<>
+			<Show
+				when={!contextMenuModes.includes(tab()?.mode ?? '') && isIos()}
+			>
+				<ContextMenu items={items()} />
+			</Show>
 			<Switch fallback={<></>}>
 				<Match when={tab()?.mode === ControllerMode.Base}>
 					<Base />
@@ -71,7 +119,7 @@ function Popup() {
 				</Match>
 			</Switch>
 
-			<Show when={!settingModes.includes(tab()?.mode ?? '')}>
+			<Show when={!settingModes.includes(tab()?.mode ?? '') && !isIos()}>
 				<a
 					href={browser.runtime.getURL('src/ui/options/index.html')}
 					class={styles.settingsIcon}
@@ -87,9 +135,10 @@ function Popup() {
 }
 
 //render the popup
-const root = document.getElementById('root');
-if (!root) {
-	throw new Error('Root element not found');
-}
+const body = document.body;
+const root = document.createElement('div');
+root.id = 'root';
+root.classList.add(styles.root);
+body.appendChild(root);
 render(Popup, root);
 void initializeThemes();
diff --git a/src/ui/popup/nowplaying.tsx b/src/ui/popup/nowplaying.tsx
index dc426b23..cb0fde83 100644
--- a/src/ui/popup/nowplaying.tsx
+++ b/src/ui/popup/nowplaying.tsx
@@ -10,6 +10,7 @@ import {
 	Switch,
 	createEffect,
 	createMemo,
+	createResource,
 	createSignal,
 	onCleanup,
 	onMount,
@@ -35,6 +36,12 @@ import {
 } from '@/util/util';
 import scrobbleService from '@/core/object/scrobble-service';
 import { SessionData } from '@/core/scrobbler/base-scrobbler';
+import { Squircle, isIos } from '../components/util';
+import ContextMenu from '../components/context-menu/context-menu';
+import {
+	Navigator,
+	getMobileNavigatorGroup,
+} from '../options/components/navigator';
 
 /**
  * Component showing info for currently playing song if there is one
@@ -96,6 +103,13 @@ export default function NowPlaying(props: { tab: Resource<ManagerTab> }) {
 				<EditComponent tab={tab} />
 			</Match>
 			<Match when={song()}>
+				<Show when={isIos()}>
+					<NowPlayingContextMenu
+						song={song}
+						tab={tab}
+						setIsEditing={setIsEditing}
+					/>
+				</Show>
 				<div class={styles.nowPlayingPopup} ref={nowplaying}>
 					<PopupLink
 						class={styles.coverArtWrapper}
@@ -114,6 +128,7 @@ export default function NowPlaying(props: { tab: Resource<ManagerTab> }) {
 								)
 							}
 						/>
+						<Squircle id="coverArtClip" />
 					</PopupLink>
 					<SongDetails
 						song={song}
@@ -126,6 +141,53 @@ export default function NowPlaying(props: { tab: Resource<ManagerTab> }) {
 	);
 }
 
+function NowPlayingContextMenu(props: {
+	song: Accessor<ClonedSong | null>;
+	tab: Resource<ManagerTab>;
+	setIsEditing: Setter<boolean>;
+}) {
+	const [navigatorResource] = createResource(getMobileNavigatorGroup);
+	const items = createMemo(() => {
+		const items: Navigator = [
+			{
+				namei18n:
+					props.tab()?.mode === ControllerMode.Playing
+						? 'infoEditTitleShort'
+						: 'infoEditUnableTitleShort',
+				icon: Edit,
+				action: () => props.setIsEditing(true),
+			},
+		];
+		if (props.song()?.flags.isCorrectedByUser) {
+			items.push({
+				namei18n:
+					props.tab()?.mode === ControllerMode.Playing
+						? 'infoRevertTitleShort'
+						: 'infoRevertUnableTitleShort',
+				icon: RestartAlt,
+				action: () => actionResetSongData(props.tab),
+			});
+		}
+		items.push({
+			namei18n: getSkipLabel(props.tab, true),
+			icon: Block,
+			action: () => actionSkipCurrentSong(props.tab),
+		});
+		if (!navigatorResource.loading) {
+			const navigatorGroup = navigatorResource();
+			if (navigatorGroup) {
+				items.push(navigatorGroup);
+			}
+		}
+		return items;
+	});
+
+	return <ContextMenu items={items()} />;
+}
+
+/**
+ * Component containing the metadata for the currently playing song
+ */
 function SongDetails(props: {
 	song: Accessor<ClonedSong | null>;
 	tab: Resource<ManagerTab>;
@@ -135,16 +197,42 @@ function SongDetails(props: {
 	return (
 		<div class={styles.songDetails}>
 			<TrackData song={song} />
+			<Show when={isIos()}>
+				<IOSLoveTrack song={song} tab={tab} />
+			</Show>
 			<TrackMetadata song={song} />
-			<TrackControls
-				song={song}
-				tab={tab}
-				setIsEditing={props.setIsEditing}
-			/>
+			<Show when={!isIos()}>
+				<TrackControls
+					song={song}
+					tab={tab}
+					setIsEditing={props.setIsEditing}
+				/>
+			</Show>
 		</div>
 	);
 }
 
+/**
+ * Component containing the button for loving track on iOS
+ */
+function IOSLoveTrack(props: {
+	tab: Resource<ManagerTab>;
+	song: Accessor<ClonedSong | null>;
+}) {
+	const { tab, song } = props;
+	return (
+		<button
+			class={`${styles.iosLoveButton}${
+				song()?.metadata.userloved ? ` ${styles.active}` : ''
+			}`}
+			onClick={() => toggleLove(tab, song)}
+			title={song()?.metadata.userloved ? t('infoUnlove') : t('infoLove')}
+		>
+			<Favorite />
+		</button>
+	);
+}
+
 /**
  * The component showing the track data.
  */
@@ -237,9 +325,7 @@ function TrackControls(props: {
 						? t('infoEditTitle')
 						: t('infoEditUnableTitle')
 				}
-				onClick={() => {
-					props.setIsEditing(true);
-				}}
+				onClick={() => props.setIsEditing(true)}
 			>
 				<Edit />
 			</button>
@@ -252,12 +338,7 @@ function TrackControls(props: {
 							? t('infoRevertTitle')
 							: t('infoRevertUnableTitle')
 					}
-					onClick={() => {
-						sendBackgroundMessage(tab()?.tabId ?? -1, {
-							type: 'resetData',
-							payload: undefined,
-						});
-					}}
+					onClick={() => actionResetSongData(tab)}
 				>
 					<RestartAlt />
 				</button>
@@ -273,13 +354,8 @@ function TrackControls(props: {
 						: ''
 				}`}
 				disabled={tab()?.mode !== ControllerMode.Playing}
-				onClick={() => {
-					sendBackgroundMessage(tab()?.tabId || -1, {
-						type: 'skipCurrentSong',
-						payload: undefined,
-					});
-				}}
-				title={t(getSkipLabel(tab))}
+				onClick={() => actionSkipCurrentSong(tab)}
+				title={t(getSkipLabel(tab, false))}
 			>
 				<Block />
 			</button>
@@ -287,14 +363,7 @@ function TrackControls(props: {
 				class={`${styles.controlButton}${
 					song()?.metadata.userloved ? ` ${styles.active}` : ''
 				}`}
-				onClick={() => {
-					sendBackgroundMessage(tab()?.tabId ?? -1, {
-						type: 'toggleLove',
-						payload: {
-							isLoved: !song()?.metadata.userloved,
-						},
-					});
-				}}
+				onClick={() => toggleLove(tab, song)}
 				title={
 					song()?.metadata.userloved ? t('infoUnlove') : t('infoLove')
 				}
@@ -341,16 +410,62 @@ function PopupLink(props: {
  * Get the correct label for the skip label based on current controller mode
  *
  * @param tab - currently active tab
+ * @param isShort - if the label should be short
  * @returns label for skip button
  */
-function getSkipLabel(tab: Resource<ManagerTab>): string {
+function getSkipLabel(tab: Resource<ManagerTab>, isShort: boolean): string {
+	let res = 'infoSkipUnableTitle';
 	switch (tab()?.mode) {
 		case ControllerMode.Playing:
-			return 'infoSkipTitle';
+			res = 'infoSkipTitle';
+			break;
 		case ControllerMode.Skipped:
-			return 'infoSkippedTitle';
+			res = 'infoSkippedTitle';
+			break;
 		case ControllerMode.Scrobbled:
-			return 'infoSkipUnableTitle';
+			res = 'infoSkipUnableTitle';
+			break;
 	}
-	return 'infoSkipUnableTitle';
+	return isShort ? `${res}Short` : res;
+}
+
+/**
+ * Skip current song
+ *
+ * @param tab - currently active tab
+ */
+function actionSkipCurrentSong(tab: Resource<ManagerTab>) {
+	sendBackgroundMessage(tab()?.tabId || -1, {
+		type: 'skipCurrentSong',
+		payload: undefined,
+	});
+}
+
+/**
+ * Reset song data
+ *
+ * @param tab - currently active tab
+ */
+function actionResetSongData(tab: Resource<ManagerTab>) {
+	sendBackgroundMessage(tab()?.tabId ?? -1, {
+		type: 'resetData',
+		payload: undefined,
+	});
+}
+
+/**
+ * Love current song
+ * @param tab - currently active tab
+ * @param song - currently playing song
+ */
+function toggleLove(
+	tab: Resource<ManagerTab>,
+	song: Accessor<ClonedSong | null>
+) {
+	sendBackgroundMessage(tab()?.tabId ?? -1, {
+		type: 'toggleLove',
+		payload: {
+			isLoved: !song()?.metadata.userloved,
+		},
+	});
 }
diff --git a/src/ui/popup/popup.module.scss b/src/ui/popup/popup.module.scss
index 36e01b89..f558838b 100644
--- a/src/ui/popup/popup.module.scss
+++ b/src/ui/popup/popup.module.scss
@@ -1,3 +1,12 @@
+@mixin iOSTextInput {
+	appearance: none;
+	background-color: var(--last-fm-bg);
+	border: none;
+	box-sizing: border-box;
+	color: var(--text);
+	font-size: 1.2em;
+}
+
 .alertPopup {
 	padding: 15px;
 	text-align: center;
@@ -76,6 +85,12 @@ a {
 		min-width: 30ch;
 		padding: 0.5em 1em;
 		white-space: nowrap;
+
+		@supports (-webkit-touch-callout: none) {
+			.editField {
+				@include iOSTextInput;
+			}
+		}
 	}
 
 	.playDetails {
@@ -274,3 +289,115 @@ a {
 		grid-area: albumartistpreview;
 	}
 }
+
+@supports (-webkit-touch-callout: none) {
+
+	.root {
+		display: flex;
+		flex-direction: column-reverse;
+		// stylelint-disable-next-line unit-no-unknown
+		height: 100dvh;
+		width: 100vw;
+	}
+
+	.nowPlayingPopup,
+	.alertPopup {
+		align-items: center;
+		box-sizing: border-box;
+		flex: 1 1 auto;
+		flex-direction: column;
+		margin: 0 auto;
+		overflow: auto;
+		overflow-y: auto;
+		padding-bottom: 1.5em;
+		width: 100%;
+	}
+
+	.nowPlayingPopup {
+		.coverArtWrapper {
+			filter: drop-shadow(0 0.25em 0.5em var(--text));
+			flex: 0 0 auto;
+			height: min(30vh, 30vw);
+			margin: 1em 0;
+			position: relative;
+
+			&::after {
+				background-color: var(--background);
+				clip-path: url(#coverArtClip);
+				content: '';
+				inset: 0;
+				position: absolute;
+				z-index: -100;
+			}
+
+			.coverArt {
+				clip-path: url(#coverArtClip);
+				display: inline-block;
+				max-width: min(30vh, 30vw);
+			}
+		}
+
+		.songDetails {
+			align-items: center;
+		}
+
+		.iosLoveButton {
+			background-color: transparent;
+			border: none;
+			color: var(--text);
+			cursor: pointer;
+			padding: 0.25em 0.5em;
+
+			&.active {
+				color: var(--last-fm);
+			}
+
+			svg {
+				height: 2em;
+				width: 2em;
+			}
+		}
+	}
+
+	.regexContainer {
+		align-items: center;
+		display: flex;
+		flex-direction: column;
+		width: 100%;
+
+		.iosSearchLabel,
+		.iosReplaceLabel,
+		.iosPreviewLabel {
+			margin-top: 0.5em;
+		}
+
+		input {
+			@include iOSTextInput;
+			width: 100%;
+		}
+
+		.entryWrapper {
+			align-items: center;
+			border: 1px solid var(--last-fm-bg);
+			border-radius: 1em;
+			display: flex;
+			flex-direction: column;
+
+			.entryWrapperLegend {
+				padding: 0 1em;
+			}
+		}
+
+		.searchWrapper {
+			align-items: center;
+			display: flex;
+			flex-direction: column;
+			width: 100%;
+
+			.regexTest {
+				bottom: -0.3em; // I have no idea why this works - and I honestly don't want to know either
+				top: auto;
+			}
+		}
+	}
+}
diff --git a/src/ui/popup/regex.tsx b/src/ui/popup/regex.tsx
index a92470e6..0e21aa50 100644
--- a/src/ui/popup/regex.tsx
+++ b/src/ui/popup/regex.tsx
@@ -5,11 +5,14 @@ import Close from '@suid/icons-material/CloseOutlined';
 
 import { t } from '@/util/i18n';
 import {
+	JSXElement,
 	Match,
 	Resource,
 	Show,
 	Switch,
 	createEffect,
+	createMemo,
+	createResource,
 	createSignal,
 } from 'solid-js';
 import {
@@ -25,6 +28,12 @@ import {
 import { ManagerTab } from '@/core/storage/wrapper';
 import regexEdits from '@/core/storage/regex-edits';
 import { sendBackgroundMessage } from '@/util/communication';
+import ContextMenu from '../components/context-menu/context-menu';
+import {
+	Navigator,
+	getMobileNavigatorGroup,
+} from '../options/components/navigator';
+import { isIos } from '../components/util';
 
 const [searches, setSearches] = createSignal({
 	track: null,
@@ -54,9 +63,11 @@ export default function Regex(props: {
 }) {
 	return (
 		<div class={styles.regexContainer}>
-			<span class={styles.searchLabel}>{t('infoSearchLabel')}</span>
-			<span class={styles.replaceLabel}>{t('infoReplaceLabel')}</span>
-			<span class={styles.previewLabel}>{t('infoPreviewLabel')}</span>
+			<Show when={!isIos()}>
+				<span class={styles.searchLabel}>{t('infoSearchLabel')}</span>
+				<span class={styles.replaceLabel}>{t('infoReplaceLabel')}</span>
+				<span class={styles.previewLabel}>{t('infoPreviewLabel')}</span>
+			</Show>
 			<Entry clonedSong={props.clonedSong} type="track" />
 			<Entry clonedSong={props.clonedSong} type="artist" />
 			<Entry clonedSong={props.clonedSong} type="album" />
@@ -87,23 +98,32 @@ function Entry(props: { clonedSong: ClonedSong; type: FieldType }) {
 	});
 
 	return (
-		<>
-			<span class={`${styles[`${type}Label`]} ${styles.entryLabel}`}>
-				{t(`info${pascalCaseField(type)}Label`)}
-			</span>
+		<EntryWrapper type={type}>
+			<Show when={!isIos()}>
+				<span class={`${styles[`${type}Label`]} ${styles.entryLabel}`}>
+					{t(`info${pascalCaseField(type)}Label`)}
+				</span>
+			</Show>
 			<SearchField type={type} clonedSong={clonedSong} />
-			<input
-				type="text"
-				class={styles[`${type}Replace`]}
-				onInput={(e) =>
-					setReplaces((prev) => ({
-						...prev,
-						[type]: e.currentTarget.value || null,
-					}))
-				}
-			/>
-			<span class={styles[`${type}Preview`]}>{previews()[type]}</span>
-		</>
+			<ReplaceField type={type} />
+			<PreviewOutput type={type} />
+		</EntryWrapper>
+	);
+}
+
+/**
+ * Wrapper for an entry that shows a fieldset on iOS and just the inputs on other platforms.
+ */
+function EntryWrapper(props: { type: FieldType; children: JSXElement }) {
+	return (
+		<Show when={isIos()} fallback={props.children}>
+			<fieldset class={styles.entryWrapper}>
+				<legend class={styles.entryWrapperLegend}>
+					{t(`info${pascalCaseField(props.type)}Label`)}
+				</legend>
+				{props.children}
+			</fieldset>
+		</Show>
 	);
 }
 
@@ -114,7 +134,13 @@ function SearchField(props: { type: FieldType; clonedSong: ClonedSong }) {
 	const { type, clonedSong } = props;
 	return (
 		<div class={`${styles[`${type}Search`]} ${styles.searchWrapper}`}>
+			<Show when={isIos()}>
+				<label for={`${type}SearchInput`} class={styles.iosSearchLabel}>
+					{t('infoSearchLabel')}
+				</label>
+			</Show>
 			<input
+				id={`${type}SearchInput`}
 				type="text"
 				class={styles.searchField}
 				onInput={(e) =>
@@ -149,6 +175,61 @@ function SearchField(props: { type: FieldType; clonedSong: ClonedSong }) {
 	);
 }
 
+/**
+ * A single replace input for a single song property.
+ */
+function ReplaceField(props: { type: FieldType }) {
+	const { type } = props;
+	return (
+		<>
+			<Show when={isIos()}>
+				<label
+					for={`${type}ReplaceInput`}
+					class={styles.iosReplaceLabel}
+				>
+					{t('infoReplaceLabel')}
+				</label>
+			</Show>
+			<input
+				id={`${type}ReplaceInput`}
+				type="text"
+				class={styles[`${type}Replace`]}
+				onInput={(e) =>
+					setReplaces((prev) => ({
+						...prev,
+						[type]: e.currentTarget.value || null,
+					}))
+				}
+			/>
+		</>
+	);
+}
+
+/**
+ * Preview of a single song property.
+ */
+function PreviewOutput(props: { type: FieldType }) {
+	const { type } = props;
+	return (
+		<>
+			<Show when={isIos()}>
+				<label
+					for={`${type}PreviewOutput`}
+					class={styles.iosPreviewLabel}
+				>
+					{t('infoPreviewLabel')}
+				</label>
+			</Show>
+			<output
+				id={`${type}PreviewOutput`}
+				class={styles[`${type}Preview`]}
+			>
+				{previews()[type]}
+			</output>
+		</>
+	);
+}
+
 /**
  * Footer of regex edit popup, allows the user to submit, and displays a warning if edit exists already.
  */
@@ -158,21 +239,48 @@ function Footer(props: { tab: Resource<ManagerTab>; clonedSong: ClonedSong }) {
 			<Show when={props.clonedSong.flags.isCorrectedByUser}>
 				<span class={styles.editWarning}>{t('infoEditedWarning')}</span>
 			</Show>
-			<div class={styles.controlButtons}>
-				<button
-					class={styles.controlButton}
-					title={t('infoSubmitTitle')}
-					onClick={() => {
-						saveEdit(props.tab);
-					}}
-				>
-					<Check />
-				</button>
-			</div>
+			<Show when={!isIos()}>
+				<div class={styles.controlButtons}>
+					<button
+						class={styles.controlButton}
+						title={t('infoSubmitTitle')}
+						onClick={() => {
+							saveEdit(props.tab);
+						}}
+					>
+						<Check />
+					</button>
+				</div>
+			</Show>
 		</div>
 	);
 }
 
+/**
+ * Component for the context menu of regex edit
+ */
+export function RegexEditContextMenu(props: { tab: Resource<ManagerTab> }) {
+	const [navigatorResource] = createResource(getMobileNavigatorGroup);
+	const items = createMemo(() => {
+		const items: Navigator = [
+			{
+				namei18n: 'infoSubmitTitleShort',
+				icon: Check,
+				action: () => saveEdit(props.tab),
+			},
+		];
+		if (!navigatorResource.loading) {
+			const navigatorGroup = navigatorResource();
+			if (navigatorGroup) {
+				items.push(navigatorGroup);
+			}
+		}
+		return items;
+	});
+
+	return <ContextMenu items={items()} />;
+}
+
 /**
  * Save edit to storage
  * @param tab - tab data of the tab whose data is being edited
diff --git a/vite.configs.ts b/vite.configs.ts
index 80ed7133..92687527 100644
--- a/vite.configs.ts
+++ b/vite.configs.ts
@@ -1,21 +1,20 @@
-import { UserConfig, normalizePath } from 'vite';
-import { resolve } from 'path';
+import { UserConfig } from 'vite';
 import * as manifests from './manifest.config';
 import solid from 'vite-plugin-solid';
 import { viteStaticCopy } from 'vite-plugin-static-copy';
 import compileConnectors from './scripts/compile-connectors';
 import makeManifest from './scripts/make-manifest';
-import { isDev, releaseTarget } from 'scripts/util';
+import { isDev, releaseTarget, resolvePath } from 'scripts/util';
 import minifyImages from 'scripts/minify-images';
 import generateIcons from 'scripts/generate-icons';
 
-const dist = resolve(import.meta.url.slice(5), '..', 'build');
-const root = resolve(import.meta.url.slice(5), '..', 'src');
+const dist = resolvePath('build');
+const root = resolvePath('src');
 const builds = {
-	chrome: resolve(dist, 'chrome'),
-	firefox: resolve(dist, 'firefox'),
-	safari: resolve(dist, 'safariraw'),
-	error: resolve(dist, 'error'),
+	chrome: resolvePath(dist, 'chrome'),
+	firefox: resolvePath(dist, 'firefox'),
+	safari: resolvePath(dist, 'safariraw'),
+	error: resolvePath(dist, 'error'),
 };
 
 const common: UserConfig = {
@@ -39,11 +38,11 @@ export const buildBackground: UserConfig = {
 	...common,
 	build: {
 		minify: !isDev(),
-		outDir: resolve(distRoot(), 'background'),
+		outDir: resolvePath(distRoot(), 'background'),
 		emptyOutDir: true,
 		watch: isDev() ? {} : null,
 		lib: {
-			entry: resolve(root, 'core', 'background', 'main.ts'),
+			entry: resolvePath(root, 'core', 'background', 'main.ts'),
 			name: manifests.common.name,
 			formats: ['iife'],
 		},
@@ -60,11 +59,11 @@ export const buildContent: UserConfig = {
 	...common,
 	build: {
 		minify: !isDev(),
-		outDir: resolve(distRoot(), 'content'),
+		outDir: resolvePath(distRoot(), 'content'),
 		emptyOutDir: true,
 		watch: isDev() ? {} : null,
 		lib: {
-			entry: resolve(root, 'core', 'content', 'main.ts'),
+			entry: resolvePath(root, 'core', 'content', 'main.ts'),
 			name: manifests.common.name,
 			formats: ['iife'],
 		},
@@ -87,8 +86,8 @@ export const buildStart: UserConfig = {
 		sourcemap: isDev(),
 		rollupOptions: {
 			input: {
-				popup: resolve(root, 'ui', 'popup', 'index.html'),
-				options: resolve(root, 'ui', 'options', 'index.html'),
+				popup: resolvePath(root, 'ui', 'popup', 'index.html'),
+				options: resolvePath(root, 'ui', 'options', 'index.html'),
 			},
 			output: {},
 		},
@@ -100,7 +99,7 @@ export const buildStart: UserConfig = {
 		viteStaticCopy({
 			targets: [
 				{
-					src: normalizePath(resolve(root, '_locales')),
+					src: resolvePath(root, '_locales'),
 					dest: '',
 				},
 			],


Test patch:
diff --git a/vitest.config.ts b/vitest.config.ts
index b422c9bd..47ea7eff 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,9 +1,9 @@
-import path from 'path';
+import { resolvePath } from './scripts/util';
 export default {
 	resolve: {
 		alias: {
-			'@': path.resolve(__dirname, './src'),
-			'#': path.resolve(__dirname, './tests'),
+			'@': resolvePath(__dirname, './src'),
+			'#': resolvePath(__dirname, './tests'),
 		},
 	},
 };