Compare commits
2
Commits
ios-v1.0.3
...
ios-v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea3f8f5a2 | ||
|
|
e0184f540b |
No files matched your search
@@ -363,6 +363,8 @@ adb forward tcp:5277 tcp:5277
|
|||||||
|
|
||||||
`ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer.
|
`ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer.
|
||||||
|
|
||||||
|
On top of the Android features: the Save chip in the mixer remembers a mix for that song and puts it back every time it plays, and the mic button turns on karaoke mode (vocals muted on every song, synced lyrics from [LRCLIB](https://lrclib.net) on Now Playing; tap a line to jump to it).
|
||||||
|
|
||||||
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`):
|
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LyricLine: Hashable {
|
||||||
|
let ms: Int64
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lyrics {
|
||||||
|
var lines: [LyricLine] = []
|
||||||
|
/// False for plain lyrics, which have no timings (every [ms] is 0).
|
||||||
|
var synced = false
|
||||||
|
var instrumental = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lyrics from LRCLIB (lrclib.net), synced when it has them. Hits are cached on disk by track,
|
||||||
|
/// misses only until the app restarts.
|
||||||
|
actor LyricsSource {
|
||||||
|
static let shared = LyricsSource()
|
||||||
|
|
||||||
|
private struct Record: Codable {
|
||||||
|
var syncedLyrics: String?
|
||||||
|
var plainLyrics: String?
|
||||||
|
var instrumental: Bool?
|
||||||
|
var duration: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
private let dir: URL
|
||||||
|
private var misses: Set<String> = []
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("lyrics", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nil when LRCLIB has nothing for this song or couldn't be reached.
|
||||||
|
func lyrics(_ t: Track) async -> Lyrics? {
|
||||||
|
let file = dir.appendingPathComponent(t.ratingKey.replacingOccurrences(of: "/", with: "_") + ".json")
|
||||||
|
if let data = try? Data(contentsOf: file), let r = try? JSONDecoder().decode(Record.self, from: data) { return Self.parse(r) }
|
||||||
|
if misses.contains(t.ratingKey) { return nil }
|
||||||
|
let secs = Double(t.durationMs) / 1000
|
||||||
|
var rec = await get("get", ["track_name": t.title, "artist_name": t.artist, "album_name": t.album, "duration": String(Int(secs.rounded()))])
|
||||||
|
.flatMap { try? JSONDecoder().decode(Record.self, from: $0) }
|
||||||
|
if rec == nil, let data = await get("search", ["track_name": t.title, "artist_name": t.artist]),
|
||||||
|
let found = try? JSONDecoder().decode([Record].self, from: data) {
|
||||||
|
// Closest length first, so a live or extended version doesn't win.
|
||||||
|
let close = found.filter { abs(($0.duration ?? 0) - secs) < 8 || secs <= 0 }
|
||||||
|
rec = close.first { $0.syncedLyrics?.isEmpty == false } ?? close.first { $0.plainLyrics?.isEmpty == false } ?? close.first
|
||||||
|
}
|
||||||
|
guard let rec else {
|
||||||
|
misses.insert(t.ratingKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(rec) { try? data.write(to: file, options: .atomic) }
|
||||||
|
return Self.parse(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func get(_ path: String, _ query: [String: String]) async -> Data? {
|
||||||
|
var c = URLComponents(string: "https://lrclib.net/api/\(path)")!
|
||||||
|
c.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||||
|
var req = URLRequest(url: c.url!, timeoutInterval: 10)
|
||||||
|
req.setValue("Stemify iOS (https://git.iamchrisama.com/cdnutter/stemify)", forHTTPHeaderField: "User-Agent")
|
||||||
|
guard let (data, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let stamp = #/\[(\d+):(\d+(?:\.\d+)?)\]/#
|
||||||
|
|
||||||
|
/// LRC: each line starts with one or more [mm:ss.xx] stamps.
|
||||||
|
private static func parse(_ r: Record) -> Lyrics {
|
||||||
|
if let lrc = r.syncedLyrics, !lrc.isEmpty {
|
||||||
|
var lines: [LyricLine] = []
|
||||||
|
for raw in lrc.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
let stamps = raw.matches(of: stamp)
|
||||||
|
guard let last = stamps.last else { continue }
|
||||||
|
let text = raw[last.range.upperBound...].trimmingCharacters(in: .whitespaces)
|
||||||
|
for m in stamps {
|
||||||
|
let ms = (Int64(m.output.1) ?? 0) * 60_000 + Int64((Double(m.output.2) ?? 0) * 1000)
|
||||||
|
lines.append(LyricLine(ms: ms, text: text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Lyrics(lines: lines.sorted { $0.ms < $1.ms }, synced: true)
|
||||||
|
}
|
||||||
|
let plain = (r.plainLyrics ?? "").split(separator: "\n", omittingEmptySubsequences: false).map { LyricLine(ms: 0, text: String($0)) }
|
||||||
|
return Lyrics(lines: plain.count > 1 || plain.first?.text.isEmpty == false ? plain : [], instrumental: r.instrumental == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
@ObservationIgnored private let eq: EqController
|
@ObservationIgnored private let eq: EqController
|
||||||
@ObservationIgnored private(set) var engine: StemEngine!
|
@ObservationIgnored private(set) var engine: StemEngine!
|
||||||
@ObservationIgnored private let queueFile: URL
|
@ObservationIgnored private let queueFile: URL
|
||||||
|
@ObservationIgnored private let songMixFile: URL
|
||||||
|
|
||||||
private(set) var queue: [QueueItem] = []
|
private(set) var queue: [QueueItem] = []
|
||||||
private(set) var index = -1
|
private(set) var index = -1
|
||||||
@@ -121,6 +122,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
private(set) var waveform: Waveform?
|
private(set) var waveform: Waveform?
|
||||||
private(set) var durationMs: Int64 = 0
|
private(set) var durationMs: Int64 = 0
|
||||||
private(set) var message: AppMessage?
|
private(set) var message: AppMessage?
|
||||||
|
/// Mixes saved for single songs (by rating key), put back whenever that song starts.
|
||||||
|
private(set) var songMixes: [String: [String: StemSetting]] = [:]
|
||||||
|
/// Karaoke mode: vocals out on every song, lyrics on Now Playing.
|
||||||
|
private(set) var karaoke = false
|
||||||
|
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
|
||||||
|
|
||||||
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
||||||
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
|
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
|
||||||
@@ -148,6 +154,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.eq = eq
|
self.eq = eq
|
||||||
queueFile = dir.appendingPathComponent("queue.json")
|
queueFile = dir.appendingPathComponent("queue.json")
|
||||||
|
songMixFile = dir.appendingPathComponent("song-mixes.json")
|
||||||
|
songMixes = (try? JSONDecoder().decode([String: [String: StemSetting]].self, from: Data(contentsOf: songMixFile))) ?? [:]
|
||||||
master = settings.masterVolume
|
master = settings.masterVolume
|
||||||
engine = StemEngine(listener: self)
|
engine = StemEngine(listener: self)
|
||||||
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
||||||
@@ -405,6 +413,42 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
pushMix()
|
pushMix()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
|
||||||
|
private func startMix(_ t: Track) -> [String: StemSetting] {
|
||||||
|
if karaoke { return Self.karaokeMix }
|
||||||
|
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let karaokeMix = ["vocals": StemSetting(muted: true)]
|
||||||
|
|
||||||
|
/// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile.
|
||||||
|
func setKaraoke(_ on: Bool) {
|
||||||
|
guard on != karaoke else { return }
|
||||||
|
karaoke = on
|
||||||
|
if on {
|
||||||
|
preKaraokeMix = mix
|
||||||
|
applyMix(Self.karaokeMix)
|
||||||
|
} else {
|
||||||
|
if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||||
|
preKaraokeMix = nil
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var songMix: [String: StemSetting]? { current.flatMap { songMixes[$0.track.ratingKey] } }
|
||||||
|
|
||||||
|
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
|
||||||
|
func forgetSongMix() { setSongMix(nil) }
|
||||||
|
|
||||||
|
private func setSongMix(_ m: [String: StemSetting]?) {
|
||||||
|
guard let key = current?.track.ratingKey else { return }
|
||||||
|
songMixes[key] = m
|
||||||
|
let all = songMixes, file = songMixFile
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
|
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
|
||||||
var s = mix[name] ?? StemSetting()
|
var s = mix[name] ?? StemSetting()
|
||||||
f(&s)
|
f(&s)
|
||||||
@@ -462,7 +506,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
if play && !activateSession() { return }
|
if play && !activateSession() { return }
|
||||||
playWhenReady = play
|
playWhenReady = play
|
||||||
buffering = true
|
buffering = true
|
||||||
if !settings.keepStemMix { mix = [:] }
|
mix = startMix(item.track)
|
||||||
changed()
|
changed()
|
||||||
save()
|
save()
|
||||||
loadTask = Task { @MainActor [weak self] in
|
loadTask = Task { @MainActor [weak self] in
|
||||||
@@ -635,7 +679,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
guard let item = current else { return }
|
guard let item = current else { return }
|
||||||
durationMs = item.track.durationMs
|
durationMs = item.track.durationMs
|
||||||
waveform = nil
|
waveform = nil
|
||||||
if !settings.keepStemMix { mix = [:] }
|
mix = startMix(item.track)
|
||||||
source = currentEntry != nil ? .Stems : .Original
|
source = currentEntry != nil ? .Stems : .Original
|
||||||
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
||||||
pushMix()
|
pushMix()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
enum NpPanel { case Art, Mixer, Queue }
|
enum NpPanel { case Art, Mixer, Queue, Lyrics }
|
||||||
|
|
||||||
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
|
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
|
||||||
switch d.kind {
|
switch d.kind {
|
||||||
@@ -105,6 +105,7 @@ struct NowPlayingScreen: View {
|
|||||||
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
||||||
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
||||||
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
||||||
|
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
@@ -123,6 +124,7 @@ struct NowPlayingScreen: View {
|
|||||||
.tint(c.label)
|
.tint(c.label)
|
||||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
|
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
|
||||||
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
||||||
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
||||||
tint = await albumColor(thumb, dark: base.dark)
|
tint = await albumColor(thumb, dark: base.dark)
|
||||||
@@ -172,6 +174,7 @@ struct NowPlayingScreen: View {
|
|||||||
HStack {
|
HStack {
|
||||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||||
|
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||||
}
|
}
|
||||||
.frame(height: 48)
|
.frame(height: 48)
|
||||||
@@ -180,6 +183,16 @@ struct NowPlayingScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a panel, or goes back to the art if it's already open.
|
/// Opens a panel, or goes back to the art if it's already open.
|
||||||
|
/// Karaoke: vocals out and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
||||||
|
private func karaoke() {
|
||||||
|
let pm = g.playback
|
||||||
|
withAnimation(.spring(duration: 0.38, bounce: 0.12)) {
|
||||||
|
if pm.karaoke && panel != .Lyrics { panel = .Lyrics; return }
|
||||||
|
pm.setKaraoke(!pm.karaoke)
|
||||||
|
panel = pm.karaoke ? .Lyrics : .Art
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func show(_ to: NpPanel) {
|
private func show(_ to: NpPanel) {
|
||||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
|
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
|
||||||
}
|
}
|
||||||
@@ -490,6 +503,7 @@ private final class Meters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct MixerPanel: View {
|
private struct MixerPanel: View {
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
@State private var meters = Meters()
|
@State private var meters = Meters()
|
||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
@@ -510,6 +524,20 @@ private struct MixerPanel: View {
|
|||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
let current = MixPresets.normalized(pm.mix)
|
let current = MixPresets.normalized(pm.mix)
|
||||||
|
let saved = pm.songMix.map(MixPresets.normalized)
|
||||||
|
let isSaved = saved == current
|
||||||
|
PresetChip(text: isSaved ? "Saved" : "Save", icon: isSaved ? "bookmark.fill" : "bookmark", selected: isSaved) {
|
||||||
|
if isSaved {
|
||||||
|
pm.forgetSongMix()
|
||||||
|
ui.overlays.hud("Removed this song's mix", "bookmark.slash")
|
||||||
|
} else {
|
||||||
|
pm.saveSongMix()
|
||||||
|
ui.overlays.hud("Mix saved for this song", "bookmark.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let saved, !isSaved {
|
||||||
|
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
|
||||||
|
}
|
||||||
ForEach(MixPresets.all) { preset in
|
ForEach(MixPresets.all) { preset in
|
||||||
if let target = preset.build(names) {
|
if let target = preset.build(names) {
|
||||||
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
||||||
@@ -549,12 +577,17 @@ private struct MixerPanel: View {
|
|||||||
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
||||||
private struct PresetChip: View {
|
private struct PresetChip: View {
|
||||||
let text: String
|
let text: String
|
||||||
|
var icon: String? = nil
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
|
HStack(spacing: 5) {
|
||||||
|
if let icon { Image(systemName: icon) }
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
.font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
|
||||||
.padding(.horizontal, 14).frame(height: 32)
|
.padding(.horizontal, 14).frame(height: 32)
|
||||||
.background(selected ? p.label : p.fill, in: Capsule())
|
.background(selected ? p.label : p.fill, in: Capsule())
|
||||||
}
|
}
|
||||||
@@ -814,3 +847,66 @@ struct MiniPlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synced lyrics follow the song (tap a line to jump there); plain ones just scroll.
|
||||||
|
private struct LyricsPanel: View {
|
||||||
|
let track: Track
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
@State private var lyrics: Lyrics?
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var at = -1
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if loading {
|
||||||
|
ProgressView()
|
||||||
|
} else if let l = lyrics, !l.lines.isEmpty {
|
||||||
|
lines(l)
|
||||||
|
} else {
|
||||||
|
Text(lyrics?.instrumental == true ? "Instrumental" : "No lyrics found")
|
||||||
|
.font(.title3.weight(.semibold)).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.task(id: track.ratingKey) {
|
||||||
|
loading = true
|
||||||
|
at = -1
|
||||||
|
lyrics = await LyricsSource.shared.lyrics(track)
|
||||||
|
loading = false
|
||||||
|
guard let l = lyrics, l.synced else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
// A little early, so the line lights up as it's sung rather than after.
|
||||||
|
let pos = g.playback.positionMs() + 250
|
||||||
|
let i = (l.lines.lastIndex { $0.ms <= pos }) ?? -1
|
||||||
|
if i != at { withAnimation(.easeOut(duration: 0.25)) { at = i } }
|
||||||
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lines(_ l: Lyrics) -> some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: l.synced ? 18 : 6) {
|
||||||
|
ForEach(Array(l.lines.enumerated()), id: \.offset) { i, line in
|
||||||
|
Text(line.text.isEmpty ? "\u{266A}" : line.text)
|
||||||
|
.font(l.synced ? .title2.weight(.bold) : .body)
|
||||||
|
.foregroundStyle(!l.synced || i == at ? p.label : p.label3)
|
||||||
|
.scaleEffect(i == at ? 1.03 : 1, anchor: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { if l.synced { g.playback.seekTo(line.ms) } }
|
||||||
|
.id(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.vertical, l.synced ? 120 : 16)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.onChange(of: at) { _, i in
|
||||||
|
if i >= 0 { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo(i, anchor: .center) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.3"
|
MARKETING_VERSION: "1.0.4"
|
||||||
CURRENT_PROJECT_VERSION: "4"
|
CURRENT_PROJECT_VERSION: "5"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "Stemify",
|
||||||
|
"identifier": "org.cdnutter.stemify.source",
|
||||||
|
"sourceURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json",
|
||||||
|
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
|
||||||
|
"website": "https://git.iamchrisama.com/cdnutter/stemify",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "Stemify",
|
||||||
|
"bundleIdentifier": "org.cdnutter.stemify",
|
||||||
|
"developerName": "cdnutter",
|
||||||
|
"subtitle": "Plex music player with live stem mixing.",
|
||||||
|
"localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. Works offline with downloads, and in the car with CarPlay.",
|
||||||
|
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
|
||||||
|
"tintColor": "#FA2D48",
|
||||||
|
"category": "entertainment",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.3",
|
||||||
|
"buildVersion": "4",
|
||||||
|
"date": "2026-10-20T09:08:18-07:00",
|
||||||
|
"localizedDescription": "Bundle ID is now org.cdnutter.stemify (delete the old app first; settings won't carry over). IPA is named stemify-<version>.ipa. Adds an AltStore/SideStore/Feather source.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.3/stemify-1.0.3.ipa",
|
||||||
|
"size": 1319672,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"appPermissions": {
|
||||||
|
"entitlements": [
|
||||||
|
"com.apple.developer.carplay-audio",
|
||||||
|
"com.apple.developer.siri"
|
||||||
|
],
|
||||||
|
"privacy": {
|
||||||
|
"NSLocalNetworkUsageDescription": "Stemify talks to Plex and the stems server on your home network.",
|
||||||
|
"NSSiriUsageDescription": "Ask Siri to play music from your Plex library."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"news": []
|
||||||
|
}
|
||||||
Reference in new issue
Block a user