From 3347720be6370110424c81727e55c7fbebca43b7 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sat, 8 Nov 2025 14:30:29 +0700 Subject: [PATCH 01/43] feat: update statcard to support ios 26 --- flo/StatCardView.swift | 100 ++++++++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 30 deletions(-) diff --git a/flo/StatCardView.swift b/flo/StatCardView.swift index 82b395c..b626026 100644 --- a/flo/StatCardView.swift +++ b/flo/StatCardView.swift @@ -35,46 +35,86 @@ struct StatCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: icon) - .foregroundColor(color) - Text(title) - .foregroundColor(.secondary) - .customFont(.body) + if #available(iOS 26.0, *) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(title) + .foregroundColor(.secondary) + .customFont(.body) - Spacer() + Spacer() - if showArrow { - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.system(size: 14)) + if showArrow { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.system(size: 14)) + } } - } - .customFont(.subheadline) + .customFont(.subheadline) - VStack(alignment: .leading, spacing: 4) { - Text(value) - .customFont(.title2) - .lineSpacing(2) - .fontWeight(.bold) - .lineLimit(2) + VStack(alignment: .leading, spacing: 4) { + Text(value) + .customFont(.title2) + .lineSpacing(2) + .fontWeight(.bold) + .lineLimit(2) - if let subtitle = subtitle { - Text(subtitle) + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.secondary) + .customFont(.subheadline) + .lineSpacing(2) + .lineLimit(2) + } + } + } + .padding() + .glassEffect(in: .rect(cornerRadius: 16)) + .frame(maxWidth: isWide ? .infinity : nil) + } else { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(title) .foregroundColor(.secondary) - .customFont(.subheadline) + .customFont(.body) + + Spacer() + + if showArrow { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.system(size: 14)) + } + } + .customFont(.subheadline) + + VStack(alignment: .leading, spacing: 4) { + Text(value) + .customFont(.title2) .lineSpacing(2) + .fontWeight(.bold) .lineLimit(2) + + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.secondary) + .customFont(.subheadline) + .lineSpacing(2) + .lineLimit(2) + } } } + .padding() + .frame(maxWidth: isWide ? .infinity : nil) + .background(Color(UIColor.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(UIColor.separator), lineWidth: 0.8) + ) } - .padding() - .frame(maxWidth: isWide ? .infinity : nil) - .background(Color(UIColor.systemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(Color(UIColor.separator), lineWidth: 0.8) - ) } } From 69f4346a1a9ce06ff4d0292e596d3ba6401b7146 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sat, 8 Nov 2025 14:31:10 +0700 Subject: [PATCH 02/43] feat: add @preconcurrency import CoreData --- flo/Shared/Services/CoreDataManager.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flo/Shared/Services/CoreDataManager.swift b/flo/Shared/Services/CoreDataManager.swift index b06cd0d..8e89551 100644 --- a/flo/Shared/Services/CoreDataManager.swift +++ b/flo/Shared/Services/CoreDataManager.swift @@ -5,7 +5,7 @@ // Created by rizaldy on 29/06/24. // -import CoreData +@preconcurrency import CoreData import Foundation class CoreDataManager: ObservableObject { @@ -52,10 +52,12 @@ class CoreDataManager: ObservableObject { request.sortDescriptors = sortDescriptors request.fetchBatchSize = batchSize + let context = self.viewContext + return await withCheckedContinuation { continuation in - viewContext.perform { + context.perform { do { - let results = try self.viewContext.fetch(request) + let results = try context.fetch(request) continuation.resume(returning: results) } catch { From 82bce842cd508dc3040b2b092b03952edf2d360a Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sat, 8 Nov 2025 18:41:38 +0700 Subject: [PATCH 03/43] feat: add FloatingPlayerGlassView --- flo/FloatingPlayerGlassView.swift | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 flo/FloatingPlayerGlassView.swift diff --git a/flo/FloatingPlayerGlassView.swift b/flo/FloatingPlayerGlassView.swift new file mode 100644 index 0000000..bbbecc9 --- /dev/null +++ b/flo/FloatingPlayerGlassView.swift @@ -0,0 +1,82 @@ +// +// FloatingPlayerGlassView.swift +// flo +// +// Created by rizaldy on 23/09/25. +// + +import NukeUI +import SwiftUI + +@available(iOS 26.0, *) +struct FloatingPlayerGlassView: View { + @ObservedObject var viewModel: PlayerViewModel + @State private var angle: Double = 0 + + var body: some View { + ZStack { + HStack { + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .clipShape( + .rect(corners: .concentric(minimum: 48), isUniform: false) + ) + .padding(.leading, 2) + } else { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .clipShape( + .rect(corners: .concentric(minimum: 48), isUniform: false) + ) + .padding(.leading, 2) + } else { + Color.gray.opacity(0.3).frame(width: 40, height: 40) + .clipShape(.rect(corners: .concentric(minimum: 48), isUniform: false)) + .padding(.leading, 2) + } + } + } + + VStack(alignment: .leading) { + Text(viewModel.nowPlaying.songName ?? "") + .customFont(.subheadline) + .fontWeight(.bold) + .lineLimit(1) + Text(viewModel.nowPlaying.artistName ?? "") + .customFont(.footnote) + .lineLimit(1) + }.frame(maxWidth: .infinity, alignment: .leading) + + HStack { + if viewModel.isMediaLoading { + ProgressView().progressViewStyle(CircularProgressViewStyle()) + } else { + Button { + viewModel.isPlaying ? viewModel.pause() : viewModel.play() + } label: { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 18)) + .disabled(viewModel.isMediaLoading) + }.foregroundColor(.accent).opacity(viewModel.isMediaFailed ? 0 : 1) + } + }.padding() + } + } + } +} + +@available(iOS 26.0, *) +struct FloatingMusicPlayerGlassView_previews: PreviewProvider { + @StateObject static var viewModel = PlayerViewModel() + + static var previews: some View { + FloatingPlayerGlassView(viewModel: viewModel) + } +} From 866e2d595371cfd7f3e58743cfad217e14fcb16f Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 1 Feb 2026 23:38:47 +0700 Subject: [PATCH 04/43] refactor: remove FloatingPlayerGlassView --- flo.xcodeproj/project.pbxproj | 4 +- flo/FloatingPlayerGlassView.swift | 82 ------------------------------- 2 files changed, 3 insertions(+), 83 deletions(-) delete mode 100644 flo/FloatingPlayerGlassView.swift diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 89ddfff..51e01fe 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -331,7 +331,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1520; - LastUpgradeCheck = 1610; + LastUpgradeCheck = 2600; TargetAttributes = { C4E8D9572B763BA900C2353E = { CreatedOnToolsVersion = 15.2; @@ -499,6 +499,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -556,6 +557,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; diff --git a/flo/FloatingPlayerGlassView.swift b/flo/FloatingPlayerGlassView.swift deleted file mode 100644 index bbbecc9..0000000 --- a/flo/FloatingPlayerGlassView.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// FloatingPlayerGlassView.swift -// flo -// -// Created by rizaldy on 23/09/25. -// - -import NukeUI -import SwiftUI - -@available(iOS 26.0, *) -struct FloatingPlayerGlassView: View { - @ObservedObject var viewModel: PlayerViewModel - @State private var angle: Double = 0 - - var body: some View { - ZStack { - HStack { - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40) - .clipShape( - .rect(corners: .concentric(minimum: 48), isUniform: false) - ) - .padding(.leading, 2) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40) - .clipShape( - .rect(corners: .concentric(minimum: 48), isUniform: false) - ) - .padding(.leading, 2) - } else { - Color.gray.opacity(0.3).frame(width: 40, height: 40) - .clipShape(.rect(corners: .concentric(minimum: 48), isUniform: false)) - .padding(.leading, 2) - } - } - } - - VStack(alignment: .leading) { - Text(viewModel.nowPlaying.songName ?? "") - .customFont(.subheadline) - .fontWeight(.bold) - .lineLimit(1) - Text(viewModel.nowPlaying.artistName ?? "") - .customFont(.footnote) - .lineLimit(1) - }.frame(maxWidth: .infinity, alignment: .leading) - - HStack { - if viewModel.isMediaLoading { - ProgressView().progressViewStyle(CircularProgressViewStyle()) - } else { - Button { - viewModel.isPlaying ? viewModel.pause() : viewModel.play() - } label: { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 18)) - .disabled(viewModel.isMediaLoading) - }.foregroundColor(.accent).opacity(viewModel.isMediaFailed ? 0 : 1) - } - }.padding() - } - } - } -} - -@available(iOS 26.0, *) -struct FloatingMusicPlayerGlassView_previews: PreviewProvider { - @StateObject static var viewModel = PlayerViewModel() - - static var previews: some View { - FloatingPlayerGlassView(viewModel: viewModel) - } -} From b06ee82e8dac269a55f0158ecaef53fd9c43b72f Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 1 Feb 2026 23:39:27 +0700 Subject: [PATCH 05/43] chore: remove unused words --- flo/Resources/Localizable.xcstrings | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 46e5278..55404b3 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -725,22 +725,6 @@ } } }, - "Login to start streaming your music by tapping the icon above" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Login to start streaming your music by tapping the icon above" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Login untuk mulai streaming musik kamu dengan mengetuk ikon di atas" - } - } - } - }, "Login to your Navidrome server to continue" : { "localizations" : { "en" : { From 9b71571ab90d334c0f967af1d90a5ba4623b83ab Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 1 Feb 2026 23:39:53 +0700 Subject: [PATCH 06/43] feat: change online status ui --- flo/Navigation/HomeView.swift | 60 +++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index e274012..7c8e92a 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -13,6 +13,37 @@ struct HomeView: View { @EnvironmentObject var floooViewModel: FloooViewModel + private enum ConnectionState { + case online + case expired + case freshInstall + } + + private var connectionState: ConnectionState { + if viewModel.isLoggedIn { + return .online + } else if hasConfiguredServer() { + return .expired + } else { + return .freshInstall + } + } + + private var statusColor: Color { + switch connectionState { + case .online: + return .green + case .expired: + return .orange + case .freshInstall: + return .clear + } + } + + private func hasConfiguredServer() -> Bool { + UserDefaults.standard.string(forKey: "serverURL") != nil + } + private func shouldShowLoginSheet() -> Binding { Binding( get: { @@ -48,8 +79,18 @@ struct HomeView: View { } } } label: { - Image(systemName: "person.crop.circle.fill") - .font(.largeTitle) + ZStack { + Image(systemName: "person.crop.circle.fill") + .font(.largeTitle) + .foregroundColor(.accentColor) + + if connectionState != .freshInstall { + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + .offset(x: 12, y: -12) + } + } } }.padding(.top) .sheet(isPresented: shouldShowLoginSheet()) { @@ -64,21 +105,6 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { - if !viewModel.isLoggedIn { - VStack { - Text("Login to start streaming your music by tapping the icon above") - .customFont(.body) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - .padding() - .overlay( - RoundedRectangle(cornerRadius: 8).stroke(Color("PlayerColor"), lineWidth: 0.8) - ) - .padding(.top, 10) - .padding(.bottom) - } - Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) .multilineTextAlignment(.leading) From c88aad0187a987de396926dde8023d10a16288de Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 1 Feb 2026 23:45:22 +0700 Subject: [PATCH 07/43] feat: add indicator for .freshInstall too --- flo/Navigation/HomeView.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index 7c8e92a..7cd225a 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -36,7 +36,7 @@ struct HomeView: View { case .expired: return .orange case .freshInstall: - return .clear + return .red } } @@ -84,12 +84,10 @@ struct HomeView: View { .font(.largeTitle) .foregroundColor(.accentColor) - if connectionState != .freshInstall { - Circle() - .fill(statusColor) - .frame(width: 10, height: 10) - .offset(x: 12, y: -12) - } + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + .offset(x: 12, y: -12) } } }.padding(.top) From 54c924a455b34a8ff585ac722baf5728d5ba63d5 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 1 Feb 2026 23:46:04 +0700 Subject: [PATCH 08/43] feat: new FloatingPlayerView style both for iOS 16+ and iOS 26+ --- flo/FloatingPlayerView.swift | 162 ++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 79 deletions(-) diff --git a/flo/FloatingPlayerView.swift b/flo/FloatingPlayerView.swift index 44426af..4d06ce0 100644 --- a/flo/FloatingPlayerView.swift +++ b/flo/FloatingPlayerView.swift @@ -8,110 +8,114 @@ import NukeUI import SwiftUI +extension View { + @ViewBuilder + func glassedEffect(in shape: some Shape, interactive: Bool = false) -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(interactive ? .regular.interactive() : .regular, in: shape) + .contentShape(shape) + } else { + self.background { + shape.glassed() + } + } + } +} + +extension Shape { + func glassed() -> some View { + ZStack { + Color.clear + .background(.ultraThinMaterial) + + LinearGradient( + gradient: Gradient(colors: [ + Color.primary.opacity(0.08), + Color.primary.opacity(0.05), + Color.primary.opacity(0.01), + Color.clear, + Color.clear, + Color.clear, + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + .mask(self) + .overlay( + self.stroke(Color.primary.opacity(0.2), lineWidth: 0.7) + ) + } +} + struct FloatingPlayerView: View { @ObservedObject var viewModel: PlayerViewModel - var range: ClosedRange = 0...1 - var body: some View { ZStack { - HStack { - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 50, height: 50) - .clipShape( - RoundedRectangle(cornerRadius: 10, style: .continuous) - ) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 50, height: 50) - .clipShape( - RoundedRectangle(cornerRadius: 5, style: .continuous) - ) - } else { - Color.gray.opacity(0.3).frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + HStack(spacing: 10) { + Group { + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in + if let image = state.image { + image.resizable().aspectRatio(contentMode: .fit) + } else { + Color.gray.opacity(0.3) + } } } } + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(radius: 2) - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 1) { Text(viewModel.nowPlaying.songName ?? "") - .foregroundColor(.white) - .customFont(.headline) + .foregroundColor(.accent) + .customFont(.callout) + .fontWeight(.bold) .lineLimit(1) + Text(viewModel.nowPlaying.artistName ?? "") - .foregroundColor(.white) - .customFont(.subheadline) + .customFont(.caption1) .lineLimit(1) - - GeometryReader { geometry in - ZStack(alignment: .leading) { - Rectangle() - .foregroundColor(Color.gray.opacity(0.3)) - .frame(height: 3) - .cornerRadius(10) - - Rectangle() - .foregroundColor(Color.white) - .frame( - width: CGFloat( - (viewModel.progress - range.lowerBound) / (range.upperBound - range.lowerBound)) - * geometry.size.width, height: 3 - ) - .cornerRadius(10).opacity(viewModel.isMediaLoading ? 0 : 1) - }.frame(height: 3) - }.frame(height: 3) } - HStack(spacing: 20) { + Spacer() + + HStack(spacing: 16) { if viewModel.isMediaLoading { - ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)) + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.7) } else { Button { viewModel.isPlaying ? viewModel.pause() : viewModel.play() } label: { Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 20)) - .disabled(viewModel.isMediaLoading) - }.opacity(viewModel.isMediaFailed ? 0 : 1) - } - }.padding() - }.padding(8).foregroundColor(.white) - }.background { - if UserDefaultsManager.playerBackground == PlayerBackground.translucent { - ZStack { - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .blur(radius: 50, opaque: true) - .edgesIgnoringSafeArea(.all) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .blur(radius: 50, opaque: true) - .edgesIgnoringSafeArea(.all) - } + .font(.system(size: 20, weight: .semibold)) + .symbolRenderingMode(.hierarchical) } + .buttonStyle(.plain) + .opacity(viewModel.isMediaFailed ? 0.3 : 1) } - - Rectangle().fill(.thinMaterial).edgesIgnoringSafeArea(.all) - }.environment(\.colorScheme, .dark) - } else { - Rectangle().fill(Color("PlayerColor")) + } + .padding(.trailing, 8) } + .padding(.horizontal, 12) + .padding(.vertical, 8) } - .cornerRadius(10).padding(8) + .contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .glassedEffect(in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 16, x: 0, y: 6) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + .padding(.horizontal, 16) + .padding(.bottom, 8) } } From c5c3ac08561a2a1563caaee9f1f0c5624cfb0d62 Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 26 Nov 2025 21:42:51 +0100 Subject: [PATCH 09/43] added filter in artist view for only album artist --- flo/ArtistDetailView.swift | 2 +- flo/ArtistsView.swift | 29 ++++++++++------ flo/Resources/Localizable.xcstrings | 3 ++ flo/Shared/Models/Artist.swift | 54 ++++++++++++++++++++++------- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/flo/ArtistDetailView.swift b/flo/ArtistDetailView.swift index c87d211..b57c918 100644 --- a/flo/ArtistDetailView.swift +++ b/flo/ArtistDetailView.swift @@ -39,7 +39,7 @@ struct ArtistDetailView: View { .padding(.bottom, 3) .frame(maxWidth: .infinity, alignment: .leading) - Text(stripBiography(biography: artist.biography)) + Text(stripBiography(biography: artist.biography ?? "")) .customFont(.subheadline) .lineSpacing(3) .multilineTextAlignment(.leading) diff --git a/flo/ArtistsView.swift b/flo/ArtistsView.swift index c9da2eb..3287176 100644 --- a/flo/ArtistsView.swift +++ b/flo/ArtistsView.swift @@ -11,17 +11,15 @@ struct ArtistsView: View { @EnvironmentObject private var viewModel: AlbumViewModel @State private var searchArtist = "" + @State private var filterAlbumArtistOnly: Bool = true let artists: [Artist] var filteredArtists: [Artist] { - if searchArtist.isEmpty { - return artists - } else { - return artists.filter { artist in - artist.name.localizedCaseInsensitiveContains(searchArtist) - || artist.fullText.localizedCaseInsensitiveContains(searchArtist) - } + artists.filter { artist in + let matchesAlbumArtist = !filterAlbumArtistOnly || artist.stats.albumartist != nil + let matchesSearch = searchArtist.isEmpty || artist.name.localizedCaseInsensitiveContains(searchArtist) + return matchesAlbumArtist && matchesSearch } } @@ -39,16 +37,16 @@ struct ArtistsView: View { Text(artist.name) .customFont(.headline) .multilineTextAlignment(.leading) - + Spacer() - + Image(systemName: "chevron.right") .foregroundColor(.gray) .font(.caption) } .padding(.horizontal) .padding(.vertical, 5) - + Divider() } } @@ -59,6 +57,17 @@ struct ArtistsView: View { .searchable( text: $searchArtist, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search" ) + .toolbar { + Menu { + Button { + self.filterAlbumArtistOnly.toggle() + } label: { + Label("Album Artist Only", systemImage: self.filterAlbumArtistOnly ? "checkmark.circle" : "circle") + } + } label: { + Label("", systemImage: "ellipsis.circle") + } + } } } } diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 55404b3..58c2e42 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -190,6 +190,9 @@ } } } + }, + "Album Artist Only" : { + }, "Album Info" : { "localizations" : { diff --git a/flo/Shared/Models/Artist.swift b/flo/Shared/Models/Artist.swift index ffc8b2e..a5a5dab 100644 --- a/flo/Shared/Models/Artist.swift +++ b/flo/Shared/Models/Artist.swift @@ -7,18 +7,48 @@ import Foundation -struct Artist: Codable, Identifiable, Hashable { - let id: String - let name: String - let fullText: String - let biography: String +struct Artist: Codable, Hashable, Identifiable { + static func == (lhs: Artist, rhs: Artist) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + let id, name, orderArtistName: String + let stats: ArtistStats + let size, albumCount, songCount: Int + let missing: Bool + let createdAt, updatedAt: String + let sortArtistName: String? + let playCount: Int? + let playDate, mbzArtistID, biography: String? + let smallImageURL, mediumImageURL, largeImageURL: String? + let externalURL: String? + let externalInfoUpdatedAt: String? + let fullText: String? + + enum CodingKeys: String, CodingKey { + case id, name, orderArtistName, stats, size, albumCount, songCount, missing, createdAt, updatedAt, sortArtistName, playCount, playDate, fullText + case mbzArtistID = "mbzArtistId" + case biography + case smallImageURL = "smallImageUrl" + case mediumImageURL = "mediumImageUrl" + case largeImageURL = "largeImageUrl" + case externalURL = "externalUrl" + case externalInfoUpdatedAt + } +} - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) +// MARK: - Stats +struct ArtistStats: Codable { + let producer, composer, artist, maincredit: Albumartist? + let albumartist, arranger, engineer, performer: Albumartist? + let mixer, lyricist, conductor: Albumartist? +} - self.id = try container.decode(String.self, forKey: .id) - self.name = try container.decode(String.self, forKey: .name) - self.fullText = try container.decodeIfPresent(String.self, forKey: .fullText) ?? "" - self.biography = try container.decodeIfPresent(String.self, forKey: .biography) ?? "" - } +// MARK: - Albumartist +struct Albumartist: Codable { + let songCount, albumCount, size: Int } From 7086798f3ca12038f3e5db6c025639aa4e4e86f0 Mon Sep 17 00:00:00 2001 From: Simon Risse <9327096+r1sim@users.noreply.github.com> Date: Sat, 13 Sep 2025 19:45:08 +0200 Subject: [PATCH 10/43] perf: use `LazyVStack` for music queue This significantly reduces the memory usage --- flo/PlayerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 36345aa..a770592 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -97,7 +97,7 @@ struct PlayerView: View { .padding(.bottom, 5) ScrollView { - VStack(alignment: .leading) { + LazyVStack(alignment: .leading) { ForEach(viewModel.queue.indices, id: \.self) { idx in HStack(alignment: .top) { VStack(alignment: .leading) { From 1e5c20d7b8783a252add1c9ea99a4049ddf3ee59 Mon Sep 17 00:00:00 2001 From: Simon Risse <9327096+r1sim@users.noreply.github.com> Date: Sat, 13 Sep 2025 19:47:53 +0200 Subject: [PATCH 11/43] perf: use batch api in addToQueue The batch api is a lot faster when inserting many of items --- flo/Shared/Services/PlaybackService.swift | 31 ++++++++++++----------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/flo/Shared/Services/PlaybackService.swift b/flo/Shared/Services/PlaybackService.swift index 2d5ca52..0a4e5bb 100644 --- a/flo/Shared/Services/PlaybackService.swift +++ b/flo/Shared/Services/PlaybackService.swift @@ -31,23 +31,24 @@ class PlaybackService { func addToQueue(item: T, isFromLocal: Bool = false) -> [QueueEntity] { self.clearQueue() - for song in item.songs { - let queue = QueueEntity(context: CoreDataManager.shared.viewContext) - - queue.id = song.mediaFileId == "" ? song.id : song.mediaFileId - queue.albumId = song.albumId - queue.albumName = item.name - queue.artistName = song.artist - queue.bitRate = Int16(song.bitRate) - queue.sampleRate = Int32(song.sampleRate) - queue.songName = song.title - queue.suffix = song.suffix - queue.isFromLocal = isFromLocal - queue.duration = song.duration - - CoreDataManager.shared.saveRecord() + let objects = item.songs.map { song in + return [ + "id": song.mediaFileId == "" ? song.id : song.mediaFileId, + "albumId": song.albumId, + "albumName": item.name, + "artistName": song.artist, + "bitRate": song.bitRate, + "sampleRate": song.sampleRate, + "songName": song.title, + "suffix": song.suffix, + "isFromLocal": isFromLocal, + "duration": song.duration + ] as [String : Any] } + let request = NSBatchInsertRequest(entity: QueueEntity.entity(), objects: objects) + _ = try? CoreDataManager.shared.viewContext.execute(request) + return self.getQueue() } } From 485e7353d268223b86087b5c38bdc977c8c364ae Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:19:24 +0700 Subject: [PATCH 12/43] feat: enable lrclib integration --- flo/Shared/Models/LRCLIB.swift | 39 ++++++++++++++++++++ flo/Shared/Models/LyricsLine.swift | 18 +++++++++ flo/Shared/Services/LRCLIBService.swift | 44 ++++++++++++++++++++++ flo/Shared/Utils/LRCParser.swift | 49 +++++++++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 flo/Shared/Models/LRCLIB.swift create mode 100644 flo/Shared/Models/LyricsLine.swift create mode 100644 flo/Shared/Services/LRCLIBService.swift create mode 100644 flo/Shared/Utils/LRCParser.swift diff --git a/flo/Shared/Models/LRCLIB.swift b/flo/Shared/Models/LRCLIB.swift new file mode 100644 index 0000000..349f178 --- /dev/null +++ b/flo/Shared/Models/LRCLIB.swift @@ -0,0 +1,39 @@ +// +// LRCLIB.swift +// flo +// +// Created by rizaldy on 01/02/26. +// + +import Foundation + +struct LRCLIBLyrics: Codable { + let id: Int? + let name: String? + let trackName: String? + let artistName: String? + let albumName: String? + let instrumental: Bool? + let plainLyrics: String? + let syncedLyrics: String? + + private let _duration: Double? + + var duration: Int? { + guard let dur = _duration else { return nil } + + return Int(dur.rounded()) + } + + enum CodingKeys: String, CodingKey { + case id + case name + case trackName + case artistName + case albumName + case _duration = "duration" + case instrumental + case plainLyrics + case syncedLyrics + } +} diff --git a/flo/Shared/Models/LyricsLine.swift b/flo/Shared/Models/LyricsLine.swift new file mode 100644 index 0000000..bb8b483 --- /dev/null +++ b/flo/Shared/Models/LyricsLine.swift @@ -0,0 +1,18 @@ +// +// LyricsLine.swift +// flo +// +// Created by rizaldy on 02/02/26. +// + +import Foundation + +struct LyricsLine: Identifiable { + let id = UUID() + let timestamp: TimeInterval + let text: String + + func isCurrentLine(currentTime: TimeInterval, threshold: TimeInterval = 0.5) -> Bool { + return abs(currentTime - timestamp) < threshold + } +} diff --git a/flo/Shared/Services/LRCLIBService.swift b/flo/Shared/Services/LRCLIBService.swift new file mode 100644 index 0000000..7d1d1dd --- /dev/null +++ b/flo/Shared/Services/LRCLIBService.swift @@ -0,0 +1,44 @@ +// +// LRCLIBService.swift +// flo +// +// Created by rizaldy on 01/02/26. +// + +import Alamofire +import Foundation + +class LRCLIBService { + static let shared = LRCLIBService() + + func fetchLyrics( + trackName: String, + artistName: String, + albumName: String? = nil, + duration: Double? = nil, + completion: @escaping (Result) -> Void + ) { + var parameters: [String: String] = [ + "track_name": trackName, + "artist_name": artistName, + ] + + if let albumName = albumName { + parameters["album_name"] = albumName + } + + if let duration = duration { + parameters["duration"] = String(Int(duration.rounded())) + } + + let request: (DataResponse) -> Void = { response in + completion(response.result.mapError { $0 as Error }) + } + + APIManager.shared.externalRequest( + url: "\(UserDefaultsManager.LRCLIBServerURL)/api/get", + parameters: parameters, + completion: request + ) + } +} diff --git a/flo/Shared/Utils/LRCParser.swift b/flo/Shared/Utils/LRCParser.swift new file mode 100644 index 0000000..e50ca46 --- /dev/null +++ b/flo/Shared/Utils/LRCParser.swift @@ -0,0 +1,49 @@ +// +// LRCParser.swift +// flo +// +// Created by rizaldy on 02/02/26. +// + +import Foundation + +class LRCParser { + static func parse(_ lrcContent: String) -> [LyricsLine] { + let pattern = #"\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)"# + + var lines: [LyricsLine] = [] + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return [] + } + + let nsRange = NSRange(lrcContent.startIndex..., in: lrcContent) + let matches = regex.matches(in: lrcContent, options: [], range: nsRange) + + for match in matches { + guard let minutesRange = Range(match.range(at: 1), in: lrcContent), + let secondsRange = Range(match.range(at: 2), in: lrcContent), + let millisecondsRange = Range(match.range(at: 3), in: lrcContent), + let textRange = Range(match.range(at: 4), in: lrcContent) + else { + continue + } + + let minutes = Double(lrcContent[minutesRange]) ?? 0 + let seconds = Double(lrcContent[secondsRange]) ?? 0 + let millisString = String(lrcContent[millisecondsRange]) + + let millisValue = Double(millisString) ?? 0.0 + let milliseconds = millisValue / (millisString.count == 2 ? 100.0 : 1000.0) + + let timestamp = minutes * 60 + seconds + milliseconds + let text = String(lrcContent[textRange]).trimmingCharacters(in: .whitespacesAndNewlines) + + if !text.isEmpty { + lines.append(LyricsLine(timestamp: timestamp, text: text)) + } + } + + return lines.sorted { $0.timestamp < $1.timestamp } + } +} From cbcfa0d50456d8e91607811c8278b07c9e32a737 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:19:48 +0700 Subject: [PATCH 13/43] feat: add externalRequest to APIManager extension --- flo/Shared/Services/APIManager.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/flo/Shared/Services/APIManager.swift b/flo/Shared/Services/APIManager.swift index 0511d0f..5ec2f4a 100644 --- a/flo/Shared/Services/APIManager.swift +++ b/flo/Shared/Services/APIManager.swift @@ -172,4 +172,21 @@ extension APIManager { completion(response) } } + + func externalRequest( + url: String, + method: HTTPMethod = .get, + parameters: Parameters? = nil, + encoding: ParameterEncoding = URLEncoding.queryString, + headers: HTTPHeaders? = nil, + completion: @escaping (DataResponse) -> Void + ) { + session.request( + url, method: method, parameters: parameters, encoding: encoding, headers: headers + ) + .validate(statusCode: 200..<300) + .responseDecodable(of: T.self) { response in + completion(response) + } + } } From 8e604e6f5b7081f2ff4bc28e248a35d4a8f1ca02 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:29:01 +0700 Subject: [PATCH 14/43] feat: API to manage lyrics display --- flo/PlayerViewModel.swift | 104 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 7fd2df2..4693739 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -27,6 +27,11 @@ class PlayerViewModel: ObservableObject { @Published var isSeeking: Bool = false @Published var isLyricsMode: Bool = false + @Published var lyrics: [LyricsLine] = [] + @Published var currentLyricsLineIndex: Int = 0 + @Published var isLoadingLyrics: Bool = false + @Published var lyricsError: String? + @Published var progress: Double = 0.0 @Published var currentTimeString: String = "00:00" @@ -53,6 +58,10 @@ class PlayerViewModel: ObservableObject { || UserDefaultsManager.maxBitRate == TranscodingSettings.sourceBitRate } + var isLRCLIBEnabled: Bool { + return UserDefaultsManager.LRCLIBServerURL != "" + } + init() { self.player = AVPlayer() self.observeInterruptionNotifications() @@ -136,6 +145,10 @@ class PlayerViewModel: ObservableObject { self.shouldHidePlayer = false self.isLocallySaved = false + if isLRCLIBEnabled { + self.resetLyrics() + } + if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) } @@ -192,6 +205,10 @@ class PlayerViewModel: ObservableObject { playbackDuration: self.totalDuration) FloooViewModel.shared.setNowPlayingToScrobbleServer(nowPlaying: self.nowPlaying) + + if isLRCLIBEnabled { + self.fetchLyrics() + } } private func addPeriodicTimeObserver() { @@ -209,6 +226,10 @@ class PlayerViewModel: ObservableObject { UserDefaultsManager.nowPlayingProgress = self.progress + if self.isLRCLIBEnabled { + self.updateCurrentLyricsLine(currentTime: currentTime) + } + if !self.isLocallySaved && self.progress >= 0.5 { Task { FloooViewModel.shared.scrobble(submission: true, nowPlaying: self.nowPlaying) @@ -354,6 +375,10 @@ class PlayerViewModel: ObservableObject { player?.seek(to: newTime) self.updateNowPlayingInfo(progress: progress, rate: 1.0) + + if isLRCLIBEnabled { + self.updateCurrentLyricsLine(currentTime: progress * totalDuration) + } } func setPlaybackMode() { @@ -469,6 +494,10 @@ class PlayerViewModel: ObservableObject { self.stop() self.progress = 0.0 + if isLRCLIBEnabled { + self.resetLyrics() + } + self.isLocallySaved = false self.shouldHidePlayer = true @@ -478,6 +507,81 @@ class PlayerViewModel: ObservableObject { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil } + func resetLyrics() { + self.lyrics = [] + self.currentLyricsLineIndex = -1 + self.lyricsError = nil + self.isLyricsMode = false + } + + func fetchLyrics() { + // just in case + guard !(self.nowPlaying.songName?.isEmpty ?? true), + !(self.nowPlaying.artistName?.isEmpty ?? true) + else { + self.lyricsError = "Missing track information" + + return + } + + self.isLoadingLyrics = true + self.lyricsError = nil + + LRCLIBService.shared.fetchLyrics( + trackName: self.nowPlaying.songName ?? "", + artistName: self.nowPlaying.artistName ?? "", + albumName: self.nowPlaying.albumName, + duration: self.nowPlaying.duration + ) { [weak self] result in + DispatchQueue.main.async { + self?.isLoadingLyrics = false + + switch result { + case .success(let response): + if let syncedLyrics = response.syncedLyrics, !syncedLyrics.isEmpty { + self?.lyrics = LRCParser.parse(syncedLyrics) + } else if let plainLyrics = response.plainLyrics, !plainLyrics.isEmpty { + self?.lyrics = [LyricsLine(timestamp: 1, text: plainLyrics)] + } else { + self?.lyricsError = "No lyrics available" + } + + case .failure: + self?.lyricsError = "Failed to load lyrics" + } + } + } + } + + func updateCurrentLyricsLine(currentTime: TimeInterval) { + guard !lyrics.isEmpty else { return } + + let lookahead: TimeInterval = 0.5 + let adjustedTime = currentTime + lookahead + + var newIndex = -1 + + for (index, line) in lyrics.enumerated() { + if adjustedTime >= line.timestamp { + newIndex = index + } else { + break + } + } + + if newIndex != currentLyricsLineIndex { + withAnimation(.easeInOut(duration: 0.1)) { + currentLyricsLineIndex = newIndex + } + } + } + + func toggleLyricsMode() { + withAnimation(.spring(duration: 0.3)) { + isLyricsMode.toggle() + } + } + deinit { if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) From 96b1a3c1f1c04e5ab5ae043a202baf4d35fe9ab7 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:34:19 +0700 Subject: [PATCH 15/43] fix: dont check on resetLyrics --- flo/PlayerViewModel.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 4693739..009e803 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -145,9 +145,7 @@ class PlayerViewModel: ObservableObject { self.shouldHidePlayer = false self.isLocallySaved = false - if isLRCLIBEnabled { - self.resetLyrics() - } + self.resetLyrics() if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) @@ -494,9 +492,7 @@ class PlayerViewModel: ObservableObject { self.stop() self.progress = 0.0 - if isLRCLIBEnabled { - self.resetLyrics() - } + self.resetLyrics() self.isLocallySaved = false self.shouldHidePlayer = true From 7fd8fbb703babc7659079404d1cfe0d2e686333d Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:42:25 +0700 Subject: [PATCH 16/43] feat: integrate with LyricsView --- flo/PlayerView.swift | 370 +++++++++++++++++++++++-------------------- 1 file changed, 197 insertions(+), 173 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index a770592..28a7d35 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -154,178 +154,17 @@ struct PlayerView: View { .animation(.spring(duration: 0.2), value: showQueue) ZStack { - VStack { - - Rectangle() - .foregroundColor(Color.gray.opacity(0.8)) - .frame(width: 50, height: 5) - .cornerRadius(30) - .padding(.top, 20) - - Spacer() - - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else { - Color.gray.opacity(0.3) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } - } - } - - Spacer() - - VStack(alignment: .center, spacing: 10) { - Text(viewModel.nowPlaying.songName ?? "") - .foregroundColor(.white) - .customFont(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(3) - - Text(viewModel.nowPlaying.artistName ?? "") - .foregroundColor(.white.opacity(0.8)) - .customFont(.title3) - .multilineTextAlignment(.center) - .lineLimit(2) - } - - Spacer() - - HStack(spacing: size.width * 0.15) { - Button { - viewModel.prevSong() - } label: { - Image(systemName: "backward.fill").font(.title) - } - - Button { - viewModel.isPlaying ? viewModel.pause() : viewModel.play() - } label: { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 50)) - } - .foregroundColor(viewModel.isMediaLoading ? .gray : .white) - .disabled(viewModel.isMediaLoading) - - Button { - viewModel.nextSong() - } label: { - Image(systemName: "forward.fill").font(.title) - } - } - - Spacer() - - VStack { - - PlayerCustomSlider( - isMediaLoading: viewModel.isMediaLoading, - isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 - ) { newValue in - viewModel.seek(to: newValue) - } - - HStack { - Text(viewModel.currentTimeString) - .foregroundColor(.white) - .customFont(.caption2) - .frame(width: 60, alignment: .leading) - - Spacer() - - Text( - viewModel.isPlayFromSource - ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" - : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)" - ) - .foregroundColor(.white) - .customFont(.caption2) - .fontWeight(.bold) - .textCase(.uppercase) - .frame(maxWidth: .infinity, alignment: .center) - - Spacer() - - Text(viewModel.totalTimeString) - .foregroundColor(.white) - .customFont(.caption2) - .frame(width: 60, alignment: .trailing) - } - } - - Spacer() - - HStack { - Button { - - } label: { - Image(systemName: "quote.bubble") - .font(.title2) - .foregroundColor(.gray) - }.disabled(true) - - Spacer() - - Button { - - } label: { - Image(systemName: "airplayaudio") - .font(.title2) - .foregroundColor(.gray) - }.disabled(true) - - Spacer() + if viewModel.isLyricsMode { + LyricsView( + viewModel: viewModel, + showQueue: $showQueue, + imageSize: imageSize + ).transition(.opacity.combined(with: .move(edge: .bottom))) + } - Button { - self.showQueue.toggle() - } label: { - Image(systemName: "list.bullet") - .font(.title2) - .overlay( - Group { - Image(systemName: "repeat") - .font(.caption) - .overlay( - Group { - Text("1") - .font(.system(size: 8)) - } - .offset(x: 7, y: -4) - .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) - ) - .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) - } - .padding(5) - .background( - .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) - ) - .clipShape(Circle()) - .offset(x: 10, y: -10) - ) - } - } + if !viewModel.isLyricsMode { + mainPlayerView(size: size, imageSize: imageSize).transition(.opacity) } - .padding(.horizontal, 30) } .frame(maxHeight: .infinity) .background { @@ -363,9 +202,11 @@ struct PlayerView: View { .gesture( DragGesture() .onChanged { gesture in - if gesture.translation.height > 0 { - offset = gesture.translation - isDragging = true + if !viewModel.isLyricsMode { + if gesture.translation.height > 0 { + offset = gesture.translation + isDragging = true + } } } .onEnded { _ in @@ -380,6 +221,189 @@ struct PlayerView: View { } .foregroundColor(.white) } + + @ViewBuilder + private func mainPlayerView(size: CGSize, imageSize: CGFloat) -> some View { + VStack { + Rectangle() + .foregroundColor(Color.gray.opacity(0.8)) + .frame(width: 50, height: 5) + .cornerRadius(30) + .padding(.top, 20) + + Spacer() + + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } else { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } else { + Color.gray.opacity(0.3) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } + } + } + + Spacer() + + VStack(alignment: .center, spacing: 10) { + Text(viewModel.nowPlaying.songName ?? "") + .foregroundColor(.white) + .customFont(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .lineLimit(3) + + Text(viewModel.nowPlaying.artistName ?? "") + .foregroundColor(.white.opacity(0.8)) + .customFont(.title3) + .multilineTextAlignment(.center) + .lineLimit(2) + } + + Spacer() + + HStack(spacing: size.width * 0.15) { + Button { + viewModel.prevSong() + } label: { + Image(systemName: "backward.fill").font(.title) + } + + Button { + viewModel.isPlaying ? viewModel.pause() : viewModel.play() + } label: { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 50)) + } + .foregroundColor(viewModel.isMediaLoading ? .gray : .white) + .disabled(viewModel.isMediaLoading) + + Button { + viewModel.nextSong() + } label: { + Image(systemName: "forward.fill").font(.title) + } + } + + Spacer() + + VStack { + PlayerCustomSlider( + isMediaLoading: viewModel.isMediaLoading, + isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 + ) { newValue in + viewModel.seek(to: newValue) + } + + HStack { + Text(viewModel.currentTimeString) + .foregroundColor(.white) + .customFont(.caption2) + .frame(width: 60, alignment: .leading) + + Spacer() + + Text( + viewModel.isPlayFromSource + ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" + : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)" + ) + .foregroundColor(.white) + .customFont(.caption2) + .fontWeight(.bold) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + + Text(viewModel.totalTimeString) + .foregroundColor(.white) + .customFont(.caption2) + .frame(width: 60, alignment: .trailing) + } + } + + Spacer() + + bottomControlBar(showQueue: $showQueue) + } + .padding(.horizontal, 30) + } + + @ViewBuilder + private func bottomControlBar(showQueue: Binding) -> some View { + HStack { + Button { + viewModel.toggleLyricsMode() + } label: { + Image(systemName: "quote.bubble") + .font(.title2) + .foregroundColor( + viewModel.lyrics.isEmpty && (viewModel.lyricsError != nil) + ? .white.opacity(0.4) : .white + ) + .padding(8) + } + + Spacer() + + Button { + // TODO: AirPlay + } label: { + Image(systemName: "airplayaudio") + .font(.title2) + .foregroundColor(.gray) + }.disabled(true) + + Spacer() + + Button { + showQueue.wrappedValue.toggle() + } label: { + Image(systemName: "list.bullet") + .font(.title2) + .overlay( + Group { + Image(systemName: "repeat") + .font(.caption) + .overlay( + Group { + Text("1") + .font(.system(size: 8)) + } + .offset(x: 7, y: -4) + .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) + ) + .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) + } + .padding(5) + .background( + .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) + ) + .clipShape(Circle()) + .offset(x: 10, y: -10) + ) + } + } + } } struct PlayerView_previews: PreviewProvider { From a1e20f62bd3a61e0334ae88ffecac19d7d092488 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:44:05 +0700 Subject: [PATCH 17/43] feat: create LyricsView UI --- flo/LyricsView.swift | 212 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 flo/LyricsView.swift diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift new file mode 100644 index 0000000..a419115 --- /dev/null +++ b/flo/LyricsView.swift @@ -0,0 +1,212 @@ +// +// LyricsView.swift +// flo +// +// Created by rizaldy on 02/02/26. +// + +import SwiftUI +import NukeUI + +struct LyricsView: View { + @ObservedObject var viewModel: PlayerViewModel + @Binding var showQueue: Bool + + let imageSize: CGFloat + + private var isPlainLyrics: Bool { + return viewModel.lyrics.count == 1 + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 16) { + Group { + if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Color.gray.opacity(0.3) + } + } + } + } + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.nowPlaying.songName ?? "") + .foregroundColor(.white) + .customFont(.body) + .fontWeight(.bold) + .lineLimit(1) + + Text(viewModel.nowPlaying.artistName ?? "") + .foregroundColor(.white.opacity(0.7)) + .customFont(.subheadline) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 30) + .padding(.top, 16) + .padding(.bottom, 16) + .onTapGesture { + viewModel.toggleLyricsMode() + } + + if viewModel.isLoadingLyrics { + Spacer() + ProgressView() + .scaleEffect(1.5) + .foregroundColor(.white) + Spacer() + } else if let error = viewModel.lyricsError { + Spacer() + VStack(spacing: 16) { + Text(error) + .foregroundColor(.white.opacity(0.7)) + .multilineTextAlignment(.center) + } + Spacer() + } else if viewModel.lyrics.isEmpty { + Spacer() + VStack(spacing: 16) { + Text("No lyrics available").foregroundColor(.white.opacity(0.7)) + } + Spacer() + } else { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 20) { + ForEach(Array(viewModel.lyrics.enumerated()), id: \.element.id) { index, line in + LyricLineView( + text: line.text, + isCurrentLine: index == viewModel.currentLyricsLineIndex, + isPastLine: index < viewModel.currentLyricsLineIndex, + isPlainLyrics: isPlainLyrics + ) + .id(index) + .onTapGesture { + guard !isPlainLyrics else { return } + + let progress = line.timestamp / viewModel.nowPlaying.duration + + viewModel.seek(to: progress) + viewModel.play() + } + } + + Spacer().frame(height: 250) + } + .padding(.horizontal, 30) + } + .onAppear { + if !isPlainLyrics { + proxy.scrollTo(viewModel.currentLyricsLineIndex, anchor: .center) + } + } + .onChange(of: viewModel.currentLyricsLineIndex) { newIndex in + withAnimation(.easeInOut(duration: 0.5)) { + if !isPlainLyrics { + proxy.scrollTo(newIndex, anchor: .center) + } + } + } + } + } + + Spacer() + + VStack(spacing: 0) { + HStack { + Button { + viewModel.toggleLyricsMode() + } label: { + Image(systemName: "quote.bubble") + .font(.title2) + .foregroundColor(.white) + .padding(8) + .background( + .white.opacity(0.1) + ) + .clipShape(.capsule) + } + + Spacer() + + Button { + // TODO: AirPlay. Also duplicates. + } label: { + Image(systemName: "airplayaudio") + .font(.title2) + .foregroundColor(.gray) + }.disabled(true) + + Spacer() + + Button { + showQueue.toggle() + } label: { + Image(systemName: "list.bullet") + .font(.title2) + .foregroundColor(.white) + .overlay( + Group { + Image(systemName: "repeat") + .font(.caption) + .overlay( + Group { + Text("1") + .font(.system(size: 8)) + } + .offset(x: 7, y: -4) + .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) + ) + .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) + } + .padding(5) + .background( + .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) + ) + .clipShape(Circle()) + .offset(x: 10, y: -10) + ) + } + } + .padding(.horizontal, 30) + .padding(.top, 10) + } + } + } +} + +struct LyricLineView: View { + let text: String + + let isCurrentLine: Bool + let isPastLine: Bool + let isPlainLyrics: Bool + + var body: some View { + Text(text) + .foregroundColor( + isCurrentLine ? .white : (isPastLine ? .white.opacity(0.3) : .white.opacity(0.5)) + ) + .customFont(.title) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .lineSpacing(6) + .scaleEffect(isCurrentLine && !isPlainLyrics ? 1.03 : 1.0) + .animation(.easeInOut(duration: 0.3), value: isCurrentLine) + .opacity(isPlainLyrics ? 0.9 : 1.0) + } +} From 9044a342ce809b5d425cba7e96c7a86c781b29fd Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:50:07 +0700 Subject: [PATCH 18/43] feat: add lrclib to experimental features --- flo/Navigation/PreferencesView.swift | 64 +++++++++++++++++++ flo/Shared/Services/UserDefaultsManager.swift | 10 +++ flo/Shared/Utils/Constants.swift | 1 + 3 files changed, 75 insertions(+) diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 069b65b..7032809 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -12,6 +12,7 @@ struct PreferencesView: View { @State private var storeCredsInKeychain = false @State private var optimizeLocalStorageAlert = false @State private var showLoginSheet = false + @State private var showCustomLRCLIBServer = false @State private var accentColor = Color(.accent) @State private var playerColor = Color(.player) @@ -21,9 +22,15 @@ struct PreferencesView: View { @EnvironmentObject var playerViewModel: PlayerViewModel let themeColors = ["Blue", "Green", "Red", "Ohio"] + let presetExperimentalLRCLIBServer: [(label: String, url: String)] = [ + ("lrclib.net", "https://lrclib.net"), + ("lrclib.flooo.club", "https://lrclib.flooo.club"), + ] @State private var experimentalMaxBitrate = UserDefaultsManager.maxBitRate @State private var experimentalPlayerBackground = UserDefaultsManager.playerBackground + @State private var experimentalLRCLIBIntegration = UserDefaultsManager.LRCLIBServerURL + @State private var customLRCLIBServer = "" var shouldShowLoginSheet: Binding { Binding( @@ -36,6 +43,21 @@ struct PreferencesView: View { ) } + var lrclibOptions: [(label: String, url: String)] { + let current = UserDefaultsManager.LRCLIBServerURL + + let isCustom = + !current.isEmpty && !presetExperimentalLRCLIBServer.contains(where: { $0.url == current }) + + var options = presetExperimentalLRCLIBServer + + if isCustom { + options.append(("Custom (\(current))", current)) + } + + return options + } + func getAppVersion() -> String { if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { return appVersion @@ -168,6 +190,29 @@ struct PreferencesView: View { ).font(.caption).foregroundColor(.gray) } + VStack(alignment: .leading) { + Picker(selection: $experimentalLRCLIBIntegration, label: Text("LRCLIB")) { + Text("Disabled").tag("") + + ForEach(lrclibOptions, id: \.url) { option in + Text(option.label).tag(option.url) + } + + Text("Add/Change Custom").tag("custom") + } + .onChange(of: experimentalLRCLIBIntegration) { value in + if value != "custom" { + UserDefaultsManager.LRCLIBServerURL = value + floooViewModel.getUserDefaults() + } else { + showCustomLRCLIBServer.toggle() + } + } + + Text("LRCLIB server is required. Learn more at dub.sh/flo-lrclib").font(.caption) + .foregroundColor(.gray) + } + VStack(alignment: .leading) { Picker(selection: $experimentalMaxBitrate, label: Text("Max Bitrate")) { ForEach(TranscodingSettings.availableBitRate, id: \.self) { bitrate in @@ -353,6 +398,25 @@ struct PreferencesView: View { floooViewModel.getUserDefaults() } } + .alert("LRCLIB Server URL", isPresented: $showCustomLRCLIBServer) { + Button("Cancel", role: .cancel) { + self.showCustomLRCLIBServer.toggle() + self.experimentalLRCLIBIntegration = "" + } + + Button("Save") { + UserDefaultsManager.LRCLIBServerURL = customLRCLIBServer + self.experimentalLRCLIBIntegration = customLRCLIBServer + floooViewModel.getUserDefaults() + } + + TextField("https://lrclib.your-server.net", text: $customLRCLIBServer).keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .textContentType(.none) + } message: { + Text("Learn more at https://dub.sh/flo-lrclib") + } } } diff --git a/flo/Shared/Services/UserDefaultsManager.swift b/flo/Shared/Services/UserDefaultsManager.swift index 6de4a5b..5d9c343 100644 --- a/flo/Shared/Services/UserDefaultsManager.swift +++ b/flo/Shared/Services/UserDefaultsManager.swift @@ -107,4 +107,14 @@ class UserDefaultsManager { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.saveLoginInfo) } } + + static var LRCLIBServerURL: String { + get { + return UserDefaults.standard.string(forKey: UserDefaultsKeys.LRCLIBServerURL) ?? "" + } + + set { + UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.LRCLIBServerURL) + } + } } diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index e0feeef..3420a0c 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -52,6 +52,7 @@ enum UserDefaultsKeys { static let enableMaxBitRate = "enableMaxBitRate" static let playerBackground = "playerBackground" static let saveLoginInfo = "saveLoginInfo" + static let LRCLIBServerURL = "LRCLIBServerURL" } enum KeychainKeys { From 3253bcf05a93826b39fe3aa76f31526e6f177cff Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:52:24 +0700 Subject: [PATCH 19/43] feat: wrap sections on VStack --- flo/Navigation/PreferencesView.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 7032809..919d046 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -289,12 +289,14 @@ struct PreferencesView: View { .foregroundColor(.gray) } - Toggle(isOn: $floooViewModel.isListenBrainzLinked) { - Text("Scrobble to ListenBrainz") - }.disabled(true) + VStack(alignment: .leading) { + Toggle(isOn: $floooViewModel.isListenBrainzLinked) { + Text("Scrobble to ListenBrainz") + }.disabled(true) - Text("To change this, please do so via the Navidrome Web UI").font(.caption) - .foregroundColor(.gray) + Text("To change this, please do so via the Navidrome Web UI").font(.caption) + .foregroundColor(.gray) + } } } From 319b6b96dab07d609c6616250b40d0e88d94de58 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 05:56:43 +0700 Subject: [PATCH 20/43] feat: remove translucent backgrounds setting this formerly experimental feature is always enabled now --- flo/Navigation/PreferencesView.swift | 10 ---------- flo/Shared/Services/UserDefaultsManager.swift | 6 +++--- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 919d046..7185b55 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -228,16 +228,6 @@ struct PreferencesView: View { ).font(.caption).foregroundColor(.gray) } - Toggle( - "Use translucent backgrounds", - isOn: Binding( - get: { UserDefaultsManager.playerBackground == PlayerBackground.translucent }, - set: { - UserDefaultsManager.playerBackground = - $0 ? PlayerBackground.translucent : PlayerBackground.solid - } - )) - VStack(alignment: .leading) { Toggle( "Save login info", diff --git a/flo/Shared/Services/UserDefaultsManager.swift b/flo/Shared/Services/UserDefaultsManager.swift index 5d9c343..507412a 100644 --- a/flo/Shared/Services/UserDefaultsManager.swift +++ b/flo/Shared/Services/UserDefaultsManager.swift @@ -90,11 +90,11 @@ class UserDefaultsManager { static var playerBackground: String { get { - return UserDefaults.standard.string(forKey: UserDefaultsKeys.playerBackground) - ?? PlayerBackground.translucent + return PlayerBackground.translucent } set { - UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.playerBackground) + UserDefaults.standard.set( + PlayerBackground.translucent, forKey: UserDefaultsKeys.playerBackground) } } From 4ff88b27f1daff957f78e0c2bf59136df4938547 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Mon, 2 Feb 2026 06:23:03 +0700 Subject: [PATCH 21/43] build(release): bump version to 2.0 --- flo.xcodeproj/project.pbxproj | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 51e01fe..c6dfc37 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -30,6 +30,11 @@ C42E7E182CE7EF5500505B4E /* PlaylistDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */; }; C440228D2C09BE2E004EE9CD /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C440228C2C09BE2E004EE9CD /* PlayerView.swift */; }; C446A6B72C08DE8800CC9787 /* UserAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C446A6B62C08DE8800CC9787 /* UserAuth.swift */; }; + C456D8F62F2FBD61002AAB8B /* LRCLIB.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */; }; + C456D8F82F2FBD64002AAB8B /* LRCLIBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */; }; + C456D8FA2F2FF33E002AAB8B /* LRCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8F92F2FF33B002AAB8B /* LRCParser.swift */; }; + C456D8FC2F2FF39B002AAB8B /* LyricsLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */; }; + C456D8FE2F300D3D002AAB8B /* LyricsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456D8FD2F300D37002AAB8B /* LyricsView.swift */; }; C45F0E2C2CE4CCEA00F75C7A /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = C45F0E2B2CE4CCEA00F75C7A /* Pulse */; }; C45F0E2E2CE4CCEA00F75C7A /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = C45F0E2D2CE4CCEA00F75C7A /* PulseUI */; }; C45F0E312CE5582C00F75C7A /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = C45F0E302CE5582C00F75C7A /* Nuke */; }; @@ -94,6 +99,11 @@ C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDetailView.swift; sourceTree = ""; }; C440228C2C09BE2E004EE9CD /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; C446A6B62C08DE8800CC9787 /* UserAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAuth.swift; sourceTree = ""; }; + C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCLIB.swift; sourceTree = ""; }; + C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCLIBService.swift; sourceTree = ""; }; + C456D8F92F2FF33B002AAB8B /* LRCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCParser.swift; sourceTree = ""; }; + C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLine.swift; sourceTree = ""; }; + C456D8FD2F300D37002AAB8B /* LyricsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsView.swift; sourceTree = ""; }; C467AD502D3264AE00644E68 /* FloooViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloooViewModel.swift; sourceTree = ""; }; C467AD522D3267CE00644E68 /* Subsonic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subsonic.swift; sourceTree = ""; }; C467AD542D329C8500644E68 /* AccountLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountLinkStatus.swift; sourceTree = ""; }; @@ -174,6 +184,8 @@ C4120FDC2C15E1C300E712BE /* Song.swift */, C49495802C1C25E5006B4D1E /* ScanStatus.swift */, C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */, + C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */, + C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */, ); path = Models; sourceTree = ""; @@ -181,6 +193,7 @@ C4289F4D2C1253EB00C3A4FD /* Utils */ = { isa = PBXGroup; children = ( + C456D8F92F2FF33B002AAB8B /* LRCParser.swift */, C415F5592C11953000E3E1D2 /* Constants.swift */, C415F5632C11AA8700E3E1D2 /* Fonts.swift */, C49134522C15BE0C00CCF2EB /* Strings.swift */, @@ -221,6 +234,7 @@ C4DE89172C2FFBC900E078CC /* CoreDataManager.swift */, C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */, C4D7F84E2C7F2C5D00165EFD /* PlaybackService.swift */, + C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */, ); path = Services; sourceTree = ""; @@ -283,6 +297,7 @@ C429DB312D33C704009F2684 /* DownloadButtonView.swift */, C429DB2F2D33AE81009F2684 /* DownloadQueueView.swift */, C4DFFA202D32E769003B9C4E /* DownloadViewModel.swift */, + C456D8FD2F300D37002AAB8B /* LyricsView.swift */, ); path = flo; sourceTree = ""; @@ -399,6 +414,7 @@ C47876022C2BF15900184A33 /* AlbumsView.swift in Sources */, C4824D272CE908DC003EAB52 /* SongsView.swift in Sources */, C4051DFF2CD25BBA0039D062 /* ArtistsView.swift in Sources */, + C456D8FA2F2FF33E002AAB8B /* LRCParser.swift in Sources */, C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */, C4DFFA212D32E76E003B9C4E /* DownloadViewModel.swift in Sources */, C4120FD92C15D58E00E712BE /* Errors.swift in Sources */, @@ -409,13 +425,16 @@ C4A4BF332C14437700363290 /* LibraryView.swift in Sources */, C46B8DD72CF4B89000B40644 /* Stats.swift in Sources */, C415F5642C11AA8700E3E1D2 /* Fonts.swift in Sources */, + C456D8FE2F300D3D002AAB8B /* LyricsView.swift in Sources */, C41E15152C0F95AD005BAE63 /* PlayerCustomSlider.swift in Sources */, C467AD512D3264B400644E68 /* FloooViewModel.swift in Sources */, C4FE524B2C14E1F70053763A /* UserDefaultsManager.swift in Sources */, C42E7E182CE7EF5500505B4E /* PlaylistDetailView.swift in Sources */, C429DB302D33AE85009F2684 /* DownloadQueueView.swift in Sources */, C4E8D95C2B763BA900C2353E /* App.swift in Sources */, + C456D8F62F2FBD61002AAB8B /* LRCLIB.swift in Sources */, C4D7F84D2C7F2AE900165EFD /* flo.xcdatamodeld in Sources */, + C456D8F82F2FBD64002AAB8B /* LRCLIBService.swift in Sources */, C4289F512C139B2E00C3A4FD /* AlbumView.swift in Sources */, C4A4BF3D2C1455A100363290 /* FloatingPlayerView.swift in Sources */, C415F54E2C11908100E3E1D2 /* AuthViewModel.swift in Sources */, @@ -428,6 +447,7 @@ C4D7F84F2C7F2C5D00165EFD /* PlaybackService.swift in Sources */, C49134532C15BE0C00CCF2EB /* Strings.swift in Sources */, C4875E002C149D9000D9BAEB /* AlbumService.swift in Sources */, + C456D8FC2F2FF39B002AAB8B /* LyricsLine.swift in Sources */, C429DB322D33C707009F2684 /* DownloadButtonView.swift in Sources */, C4824D232CE8C41F003EAB52 /* Playable.swift in Sources */, C41E15132C0F952A005BAE63 /* PlayerViewModel.swift in Sources */, @@ -570,7 +590,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 170; + CURRENT_PROJECT_VERSION = 200; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; @@ -588,7 +608,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.penerbangwalet.flo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -610,7 +630,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 170; + CURRENT_PROJECT_VERSION = 200; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; @@ -628,11 +648,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.penerbangwalet.flo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.penerbangwalet.flo 1726141004"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.penerbangwalet.flo 1769987359"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; From 62de9ca35395a14c5f3a5edafb00062978e71dea Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 00:03:04 +0700 Subject: [PATCH 22/43] feat: make lyrics work with playlists too --- flo/AlbumView.swift | 31 +++++----- flo/Navigation/LibraryView.swift | 4 +- flo/PlayerView.swift | 4 +- flo/PlayerViewModel.swift | 26 +++++++- flo/Shared/Models/Song.swift | 62 ++++++++++++++++++- flo/Shared/Services/AlbumService.swift | 14 ++++- flo/Shared/Services/PlaybackService.swift | 35 +++++++---- flo/SongView.swift | 32 +++++----- flo/flo.xcdatamodeld/flo.xcdatamodel/contents | 5 +- 9 files changed, 159 insertions(+), 54 deletions(-) diff --git a/flo/AlbumView.swift b/flo/AlbumView.swift index f6e8b58..c5a9622 100644 --- a/flo/AlbumView.swift +++ b/flo/AlbumView.swift @@ -277,42 +277,43 @@ struct AlbumView: View { struct AlbumViewPreview_Previews: PreviewProvider { static var songs: [Song] = [ Song( - id: "0", title: "Song 1", albumId: "", artist: "", trackNumber: 1, discNumber: 0, bitRate: 0, + id: "0", title: "Song 1", albumId: "", albumName: "Album name", artist: "", + trackNumber: 1, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "0"), Song( - id: "1", title: "Song 2", albumId: "", artist: "Artist Name", trackNumber: 2, discNumber: 0, - bitRate: 0, + id: "1", title: "Song 2", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 2, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "1"), Song( - id: "2", title: "Song 3", albumId: "", artist: "Artist Name", trackNumber: 3, discNumber: 0, - bitRate: 0, + id: "2", title: "Song 3", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 3, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "2"), Song( - id: "3", title: "Song 4", albumId: "", artist: "Artist Name", trackNumber: 4, discNumber: 0, - bitRate: 0, + id: "3", title: "Song 4", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 4, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "3"), Song( - id: "4", title: "Song 6", albumId: "", artist: "Artist Name", trackNumber: 5, discNumber: 0, - bitRate: 0, + id: "4", title: "Song 6", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 5, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "4"), Song( - id: "5", title: "Song 6", albumId: "", artist: "Artist Name", trackNumber: 6, discNumber: 0, - bitRate: 0, + id: "5", title: "Song 6", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 6, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "5"), Song( - id: "6", title: "Song 7", albumId: "", artist: "Artist Name", trackNumber: 7, discNumber: 0, - bitRate: 0, + id: "6", title: "Song 7", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 7, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "6"), Song( - id: "7", title: "Song 8", albumId: "", artist: "Artist Name", trackNumber: 8, discNumber: 0, - bitRate: 0, + id: "7", title: "Song 8", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 8, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "7"), ] diff --git a/flo/Navigation/LibraryView.swift b/flo/Navigation/LibraryView.swift index e1ec4f2..1bf8acf 100644 --- a/flo/Navigation/LibraryView.swift +++ b/flo/Navigation/LibraryView.swift @@ -171,8 +171,8 @@ struct LibraryView: View { struct LibraryView_Previews: PreviewProvider { static private var songs: [Song] = [ Song( - id: "0", title: "Song name", albumId: "", artist: "", trackNumber: 1, discNumber: 0, - bitRate: 0, + id: "0", title: "Song name", albumId: "", albumName: "Album 1", artist: "", + trackNumber: 1, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "m4a", duration: 100, mediaFileId: "0") ] diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 28a7d35..98b2acc 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -51,7 +51,9 @@ struct PlayerView: View { if viewModel.queue.isEmpty { Text("").customFont(.subheadline) } else { - Text("From \(viewModel.nowPlaying.albumName ?? "")").customFont(.subheadline) + Text( + "From \(viewModel.nowPlaying.contextName ?? viewModel.nowPlaying.albumName ?? "")" + ).customFont(.subheadline) } Spacer() diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 009e803..d65c7b6 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -133,8 +133,12 @@ class PlayerViewModel: ObservableObject { func getAlbumCoverArt() -> String { return AlbumService.shared.getAlbumCover( - artistName: self.nowPlaying.artistName ?? "", albumName: self.nowPlaying.albumName ?? "", - albumId: self.nowPlaying.albumId ?? "", trackId: self.nowPlaying.id ?? "") + artistName: self.nowPlaying.artistName ?? "", + albumName: self.nowPlaying.albumName ?? "", + albumId: self.nowPlaying.albumId ?? "", + trackId: self.nowPlaying.id ?? "", + contextName: self.nowPlaying.contextName + ) } func hasNowPlaying() -> Bool { @@ -523,10 +527,26 @@ class PlayerViewModel: ObservableObject { self.isLoadingLyrics = true self.lyricsError = nil + let albumName = self.nowPlaying.albumName?.trimmingCharacters(in: .whitespacesAndNewlines) + let contextName = self.nowPlaying.contextName?.trimmingCharacters(in: .whitespacesAndNewlines) + let isFromPlaylist = self.nowPlaying.isFromPlaylist + + let albumNameForLyrics: String? + + if isFromPlaylist { + if let albumName, !albumName.isEmpty, albumName != contextName { + albumNameForLyrics = albumName + } else { + albumNameForLyrics = nil + } + } else { + albumNameForLyrics = (albumName?.isEmpty == false) ? albumName : nil + } + LRCLIBService.shared.fetchLyrics( trackName: self.nowPlaying.songName ?? "", artistName: self.nowPlaying.artistName ?? "", - albumName: self.nowPlaying.albumName, + albumName: albumNameForLyrics, duration: self.nowPlaying.duration ) { [weak self] result in DispatchQueue.main.async { diff --git a/flo/Shared/Models/Song.swift b/flo/Shared/Models/Song.swift index ea5f5b8..ee9fa27 100644 --- a/flo/Shared/Models/Song.swift +++ b/flo/Shared/Models/Song.swift @@ -12,6 +12,7 @@ struct Song: Codable, Identifiable, Hashable { let title: String let artist: String let albumId: String + let albumName: String let trackNumber: Int let discNumber: Int let bitRate: Int @@ -22,11 +23,28 @@ struct Song: Codable, Identifiable, Hashable { var mediaFileId: String = "" var fileUrl: String = "" - enum CodingKeys: CodingKey { + enum DecodeKeys: String, CodingKey { case id case title case artist case albumId + case album + case albumName + case trackNumber + case discNumber + case bitRate + case sampleRate + case suffix + case duration + case mediaFileId + } + + enum EncodeKeys: String, CodingKey { + case id + case title + case artist + case albumId + case albumName = "album" case trackNumber case discNumber case bitRate @@ -37,12 +55,16 @@ struct Song: Codable, Identifiable, Hashable { } init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let container = try decoder.container(keyedBy: DecodeKeys.self) self.id = try container.decode(String.self, forKey: .id) self.title = try container.decode(String.self, forKey: .title) self.artist = try container.decode(String.self, forKey: .artist) self.albumId = try container.decode(String.self, forKey: .albumId) + self.albumName = try container.decodeIfPresent(String.self, forKey: .album) + ?? container.decodeIfPresent(String.self, forKey: .albumName) + ?? "" + self.trackNumber = try container.decode(Int.self, forKey: .trackNumber) self.discNumber = try container.decode(Int.self, forKey: .discNumber) self.bitRate = try container.decode(Int.self, forKey: .bitRate) @@ -52,8 +74,26 @@ struct Song: Codable, Identifiable, Hashable { self.mediaFileId = try container.decodeIfPresent(String.self, forKey: .mediaFileId) ?? "" } + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: EncodeKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encode(artist, forKey: .artist) + try container.encode(albumId, forKey: .albumId) + try container.encode(albumName, forKey: .albumName) + try container.encode(trackNumber, forKey: .trackNumber) + try container.encode(discNumber, forKey: .discNumber) + try container.encode(bitRate, forKey: .bitRate) + try container.encode(sampleRate, forKey: .sampleRate) + try container.encode(suffix, forKey: .suffix) + try container.encode(duration, forKey: .duration) + try container.encode(mediaFileId, forKey: .mediaFileId) + } + init( - id: String, title: String, albumId: String, artist: String, trackNumber: Int, discNumber: Int, + id: String, title: String, albumId: String, albumName: String, artist: String, + trackNumber: Int, discNumber: Int, bitRate: Int, sampleRate: Int, suffix: String, duration: Double, mediaFileId: String @@ -62,6 +102,7 @@ struct Song: Codable, Identifiable, Hashable { self.title = title self.artist = artist self.albumId = albumId + self.albumName = albumName self.trackNumber = Int(trackNumber) self.discNumber = Int(discNumber) self.bitRate = Int(bitRate) @@ -76,6 +117,21 @@ struct Song: Codable, Identifiable, Hashable { self.title = song.title ?? "N/A" self.artist = song.artistName ?? "N/A" self.albumId = song.albumId ?? "" + + if let storedAlbumName = song.albumName, !storedAlbumName.isEmpty { + self.albumName = storedAlbumName + } else if let fileURL = song.fileURL { + let parts = fileURL.split(separator: "/") + + if parts.count >= 3 { + self.albumName = String(parts[2]) + } else { + self.albumName = "" + } + } else { + self.albumName = "" + } + self.trackNumber = Int(song.trackNumber) self.discNumber = Int(song.discNumber) self.bitRate = Int(song.bitRate) diff --git a/flo/Shared/Services/AlbumService.swift b/flo/Shared/Services/AlbumService.swift index 8407114..b1a71a5 100644 --- a/flo/Shared/Services/AlbumService.swift +++ b/flo/Shared/Services/AlbumService.swift @@ -203,13 +203,21 @@ class AlbumService { } func getAlbumCover( - artistName: String, albumName: String, albumId: String = "", trackId: String = "" + artistName: String, + albumName: String, + albumId: String = "", + trackId: String = "", + contextName: String? = nil ) -> String { let target = "Media/\(artistName)/\(albumName)/cover.png" let anotherTarget = "Media/Various Artists/\(albumName)/cover/\(trackId).png" + let contextTarget = + contextName.map { "Media/Various Artists/\($0)/cover/\(trackId).png" } if LocalFileManager.shared.fileExists(fileName: target) { return LocalFileManager.shared.fileURL(for: target)?.path ?? "" + } else if let contextTarget, LocalFileManager.shared.fileExists(fileName: contextTarget) { + return LocalFileManager.shared.fileURL(for: contextTarget)?.path ?? "" } else if LocalFileManager.shared.fileExists(fileName: anotherTarget) { return LocalFileManager.shared.fileURL(for: anotherTarget)?.path ?? "" } else { @@ -284,14 +292,18 @@ class AlbumService { let fileURL = "Media/\(isFromPlaylist ? "Various Artists" : song.artist)/\(albumName ?? "Unknown Albums")/\(Int16(song.trackNumber)) \(song.title).\(song.suffix)" + let resolvedAlbumName = !song.albumName.isEmpty ? song.albumName : (albumName ?? "") + if let existingSong = checkExistingSong.first { existingSong.fileURL = "Media/\(isFromPlaylist ? "Various Artists" : song.artist)/\(albumName ?? "Unknown Albums")/\(Int16(song.trackNumber)) \(song.title).\(song.suffix)" + existingSong.albumName = resolvedAlbumName existingSong.status = status } else { let downloadedSong = SongEntity(context: CoreDataManager.shared.viewContext) downloadedSong.albumId = albumId + downloadedSong.albumName = resolvedAlbumName downloadedSong.id = songId downloadedSong.title = song.title downloadedSong.artistName = song.artist diff --git a/flo/Shared/Services/PlaybackService.swift b/flo/Shared/Services/PlaybackService.swift index 0a4e5bb..2b4edd9 100644 --- a/flo/Shared/Services/PlaybackService.swift +++ b/flo/Shared/Services/PlaybackService.swift @@ -31,19 +31,30 @@ class PlaybackService { func addToQueue(item: T, isFromLocal: Bool = false) -> [QueueEntity] { self.clearQueue() + let isPlaylist = item is Playlist + let isPlaylistAlbum = + (item as? Album).map { album in + album.artist == "Various Artists" && album.albumArtist == "Various Artists" + && album.genre.contains(" by ") + } ?? false + + let isFromPlaylist = isPlaylist || isPlaylistAlbum + let objects = item.songs.map { song in - return [ - "id": song.mediaFileId == "" ? song.id : song.mediaFileId, - "albumId": song.albumId, - "albumName": item.name, - "artistName": song.artist, - "bitRate": song.bitRate, - "sampleRate": song.sampleRate, - "songName": song.title, - "suffix": song.suffix, - "isFromLocal": isFromLocal, - "duration": song.duration - ] as [String : Any] + return [ + "id": song.mediaFileId == "" ? song.id : song.mediaFileId, + "albumId": song.albumId, + "albumName": song.albumName.isEmpty ? item.name : song.albumName, + "contextName": item.name, + "artistName": song.artist, + "bitRate": song.bitRate, + "sampleRate": song.sampleRate, + "songName": song.title, + "suffix": song.suffix, + "isFromPlaylist": isFromPlaylist, + "isFromLocal": isFromLocal, + "duration": song.duration, + ] as [String: Any] } let request = NSBatchInsertRequest(entity: QueueEntity.entity(), objects: objects) diff --git a/flo/SongView.swift b/flo/SongView.swift index 82a93a7..a4022be 100644 --- a/flo/SongView.swift +++ b/flo/SongView.swift @@ -97,43 +97,43 @@ struct SongView: View { struct SongView_Previews: PreviewProvider { static let songs: [Song] = [ Song( - id: "0", title: "Song 1", albumId: "", artist: "Artist Name", trackNumber: 1, discNumber: 0, - bitRate: 0, + id: "0", title: "Song 1", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 1, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "0"), Song( - id: "1", title: "Song 2", albumId: "", artist: "Artist Name", trackNumber: 2, discNumber: 0, - bitRate: 0, + id: "1", title: "Song 2", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 2, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "1"), Song( - id: "2", title: "Song 3", albumId: "", artist: "Artist Name", trackNumber: 3, discNumber: 0, - bitRate: 0, + id: "2", title: "Song 3", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 3, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "2"), Song( - id: "3", title: "Song 4", albumId: "", artist: "Artist Name", trackNumber: 4, discNumber: 0, - bitRate: 0, + id: "3", title: "Song 4", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 4, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "3"), Song( - id: "4", title: "Song 5", albumId: "", artist: "Artist Name", trackNumber: 5, discNumber: 0, - bitRate: 0, + id: "4", title: "Song 5", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 5, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "4"), Song( - id: "5", title: "Song 6", albumId: "", artist: "Artist Name", trackNumber: 6, discNumber: 0, - bitRate: 0, + id: "5", title: "Song 6", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 6, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "5"), Song( - id: "6", title: "Song 7", albumId: "", artist: "Artist Name", trackNumber: 7, discNumber: 0, - bitRate: 0, + id: "6", title: "Song 7", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 7, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "6"), Song( - id: "7", title: "Song 8", albumId: "", artist: "Artist Name", trackNumber: 8, discNumber: 0, - bitRate: 0, + id: "7", title: "Song 8", albumId: "", albumName: "Album name", artist: "Artist Name", + trackNumber: 8, discNumber: 0, bitRate: 0, sampleRate: 44100, suffix: "mp4a", duration: 200, mediaFileId: "7"), ] diff --git a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents index 2e4e5cc..40af39c 100644 --- a/flo/flo.xcdatamodeld/flo.xcdatamodel/contents +++ b/flo/flo.xcdatamodeld/flo.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -28,15 +28,18 @@ + + + From fdd5fc7fb87312ae6d99238315a76eef67fa9505 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 00:25:35 +0700 Subject: [PATCH 23/43] feat: make height on stats card equal --- flo/Navigation/HomeView.swift | 32 ++++++++------- flo/StatCardView.swift | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index 7cd225a..5af6def 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -106,21 +106,25 @@ struct HomeView: View { Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) .multilineTextAlignment(.leading) - HStack(alignment: .top, spacing: 16) { - StatCard( - title: "Total Listens", - value: floooViewModel.totalPlay.description, - icon: "headphones", - color: .purple - ) + EqualHeightHStack(alignment: .top, spacing: 16) { + EqualHeightItem { + StatCard( + title: "Total Listens", + value: floooViewModel.totalPlay.description, + icon: "headphones", + color: .purple + ) + } - StatCard( - title: "Top Artist", - value: floooViewModel.stats?.topArtist ?? "N/A", - icon: "music.mic", - color: .blue, - showArrow: true - ) + EqualHeightItem { + StatCard( + title: "Top Artist", + value: floooViewModel.stats?.topArtist ?? "N/A", + icon: "music.mic", + color: .blue, + showArrow: true + ) + } } HStack(alignment: .top, spacing: 16) { diff --git a/flo/StatCardView.swift b/flo/StatCardView.swift index b626026..ce2d0f7 100644 --- a/flo/StatCardView.swift +++ b/flo/StatCardView.swift @@ -118,3 +118,80 @@ struct StatCard: View { } } } + +private struct MaxHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct EqualHeightValueKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +extension EnvironmentValues { + fileprivate var equalHeightValue: CGFloat { + get { self[EqualHeightValueKey.self] } + set { self[EqualHeightValueKey.self] = newValue } + } +} + +struct EqualHeightItem: View { + @State private var ownHeight: CGFloat = 0 + @Environment(\.equalHeightValue) private var equalHeightValue + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .frame(minHeight: shouldExpand ? equalHeightValue : nil, alignment: .top) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: MaxHeightPreferenceKey.self, value: proxy.size.height) + .onAppear { + ownHeight = proxy.size.height + } + .onChange(of: proxy.size.height) { newValue in + ownHeight = newValue + } + } + ) + } + + private var shouldExpand: Bool { + equalHeightValue > 0 && ownHeight > 0 && ownHeight < equalHeightValue + } +} + +struct EqualHeightHStack: View { + let alignment: VerticalAlignment + let spacing: CGFloat? + + @ViewBuilder let content: () -> Content + @State private var maxHeight: CGFloat = 0 + + init( + alignment: VerticalAlignment = .center, + spacing: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content + } + + var body: some View { + HStack(alignment: alignment, spacing: spacing) { + content() + } + .frame(height: maxHeight == 0 ? nil : maxHeight, alignment: alignment == .top ? .top : .center) + .environment(\.equalHeightValue, maxHeight) + .onPreferenceChange(MaxHeightPreferenceKey.self) { newValue in + if maxHeight != newValue { + maxHeight = newValue + } + } + } +} From eb032292b500399e035459962f5a5d9ab7c8444e Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 02:20:29 +0700 Subject: [PATCH 24/43] feat: implement airplay route picker --- flo/LyricsView.swift | 40 ++++++++++++----------- flo/PlayerView.swift | 34 +++++++++++-------- flo/PlayerViewModel.swift | 32 ++++++++++++++++++ flo/Shared/Utils/AirPlayRoutePicker.swift | 30 +++++++++++++++++ 4 files changed, 103 insertions(+), 33 deletions(-) create mode 100644 flo/Shared/Utils/AirPlayRoutePicker.swift diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index a419115..c64562c 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -126,31 +126,32 @@ struct LyricsView: View { Spacer() VStack(spacing: 0) { - HStack { + HStack(spacing: 0) { Button { viewModel.toggleLyricsMode() } label: { - Image(systemName: "quote.bubble") + Image(systemName: "quote.bubble.fill") .font(.title2) .foregroundColor(.white) - .padding(8) - .background( - .white.opacity(0.1) - ) - .clipShape(.capsule) } - - Spacer() - - Button { - // TODO: AirPlay. Also duplicates. - } label: { - Image(systemName: "airplayaudio") - .font(.title2) - .foregroundColor(.gray) - }.disabled(true) - - Spacer() + .frame(width: 56, alignment: .leading) + + AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) + .frame(width: 36, height: 36, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) + .overlay(alignment: .bottom) { + if let outputName = viewModel.externalOutputName { + Text(outputName) + .foregroundColor(.white) + .customFont(.caption2) + .fontWeight(.bold) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + .fixedSize(horizontal: false, vertical: true) + .offset(y: 13) + } + } Button { showQueue.toggle() @@ -180,6 +181,7 @@ struct LyricsView: View { .offset(x: 10, y: -10) ) } + .frame(width: 56, alignment: .trailing) } .padding(.horizontal, 30) .padding(.top, 10) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 98b2acc..478ee74 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -352,7 +352,7 @@ struct PlayerView: View { @ViewBuilder private func bottomControlBar(showQueue: Binding) -> some View { - HStack { + HStack(spacing: 0) { Button { viewModel.toggleLyricsMode() } label: { @@ -362,20 +362,25 @@ struct PlayerView: View { viewModel.lyrics.isEmpty && (viewModel.lyricsError != nil) ? .white.opacity(0.4) : .white ) - .padding(8) } - - Spacer() - - Button { - // TODO: AirPlay - } label: { - Image(systemName: "airplayaudio") - .font(.title2) - .foregroundColor(.gray) - }.disabled(true) - - Spacer() + .frame(width: 56, alignment: .leading) + + AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) + .frame(width: 36, height: 36, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) + .overlay(alignment: .bottom) { + if let outputName = viewModel.externalOutputName { + Text(outputName) + .foregroundColor(.white) + .customFont(.caption2) + .fontWeight(.bold) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + .fixedSize(horizontal: false, vertical: true) + .offset(y: 13) + } + } Button { showQueue.wrappedValue.toggle() @@ -404,6 +409,7 @@ struct PlayerView: View { .offset(x: 10, y: -10) ) } + .frame(width: 56, alignment: .trailing) } } } diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index d65c7b6..95b12e4 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -37,6 +37,7 @@ class PlayerViewModel: ObservableObject { @Published var currentTimeString: String = "00:00" @Published var totalTimeString: String = "00:00" @Published var shouldHidePlayer: Bool = false + @Published var externalOutputName: String? // FIXME: this make confusion with `isDownloaded` and/or `isPlayingFromLocal` @Published var _playFromLocal: Bool = false @@ -46,6 +47,7 @@ class PlayerViewModel: ObservableObject { private var totalDuration: Double = 0.0 private var playerItemObservation: AnyCancellable? private var interruptionObservation = Set() + private var routeChangeObservation = Set() private var scrobbleThreshold = 0.5 @@ -65,6 +67,8 @@ class PlayerViewModel: ObservableObject { init() { self.player = AVPlayer() self.observeInterruptionNotifications() + self.observeRouteChangeNotifications() + self.updateAudioRoute() let lastPlayData = PlaybackService.shared.getQueue() let queueActiveIdx = UserDefaultsManager.queueActiveIdx @@ -97,6 +101,34 @@ class PlayerViewModel: ObservableObject { .store(in: &interruptionObservation) } + func observeRouteChangeNotifications() { + NotificationCenter.default + .publisher(for: AVAudioSession.routeChangeNotification) + .sink { _ in + self.updateAudioRoute() + } + .store(in: &routeChangeObservation) + } + + func updateAudioRoute() { + let outputs = AVAudioSession.sharedInstance().currentRoute.outputs + + if let externalOutput = outputs.first(where: { !Self.isInternalAudioOutput($0) }) { + self.externalOutputName = externalOutput.portName + } else { + self.externalOutputName = nil + } + } + + private static func isInternalAudioOutput(_ output: AVAudioSessionPortDescription) -> Bool { + switch output.portType { + case .builtInReceiver, .builtInSpeaker, .builtInMic: + return true + default: + return false + } + } + func handleInterruptionNotification(_ notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? Int, diff --git a/flo/Shared/Utils/AirPlayRoutePicker.swift b/flo/Shared/Utils/AirPlayRoutePicker.swift new file mode 100644 index 0000000..078dfbe --- /dev/null +++ b/flo/Shared/Utils/AirPlayRoutePicker.swift @@ -0,0 +1,30 @@ +// +// AirPlayRoutePicker.swift +// flo +// +// Created by rizaldy on 03/02/26. +// + +import AVKit +import SwiftUI + +struct AirPlayRoutePicker: UIViewRepresentable { + var tintColor: UIColor = .white + var activeTintColor: UIColor = .white + + func makeUIView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + + view.backgroundColor = .clear + view.prioritizesVideoDevices = false + view.tintColor = tintColor + view.activeTintColor = activeTintColor + + return view + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) { + uiView.tintColor = tintColor + uiView.activeTintColor = activeTintColor + } +} From aff3f89dbcb83d7b065e0d0bb8835e6924b1118c Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 02:29:06 +0700 Subject: [PATCH 25/43] feat: add spacing too VStack --- flo/Navigation/PreferencesView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 7185b55..5646c54 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -174,7 +174,7 @@ struct PreferencesView: View { // TODO: finish this later Section(header: Text("Experimental")) { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { Toggle( "Enable Debug", isOn: Binding( @@ -213,7 +213,7 @@ struct PreferencesView: View { .foregroundColor(.gray) } - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { Picker(selection: $experimentalMaxBitrate, label: Text("Max Bitrate")) { ForEach(TranscodingSettings.availableBitRate, id: \.self) { bitrate in Text(bitrate == "0" ? "Source" : bitrate).tag(bitrate) @@ -228,7 +228,7 @@ struct PreferencesView: View { ).font(.caption).foregroundColor(.gray) } - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { Toggle( "Save login info", isOn: Binding( @@ -270,7 +270,7 @@ struct PreferencesView: View { } if authViewModel.isLoggedIn { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 6) { Toggle(isOn: $floooViewModel.isLastFmLinked) { Text("Scrobble to Last.fm") }.disabled(true) @@ -279,7 +279,7 @@ struct PreferencesView: View { .foregroundColor(.gray) } - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 6) { Toggle(isOn: $floooViewModel.isListenBrainzLinked) { Text("Scrobble to ListenBrainz") }.disabled(true) From 0903e31907724ef1fe9582eb3179b4a6467aeb4e Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 02:56:10 +0700 Subject: [PATCH 26/43] fix: jitter on lyric change in some conditions --- flo/LyricsView.swift | 16 +++++++++------- flo/PlayerViewModel.swift | 4 +--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index c64562c..0b2291a 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -5,8 +5,8 @@ // Created by rizaldy on 02/02/26. // -import SwiftUI import NukeUI +import SwiftUI struct LyricsView: View { @ObservedObject var viewModel: PlayerViewModel @@ -109,15 +109,17 @@ struct LyricsView: View { .padding(.horizontal, 30) } .onAppear { - if !isPlainLyrics { - proxy.scrollTo(viewModel.currentLyricsLineIndex, anchor: .center) - } + guard !isPlainLyrics else { return } + guard viewModel.currentLyricsLineIndex >= 0 else { return } + + proxy.scrollTo(viewModel.currentLyricsLineIndex, anchor: .center) } .onChange(of: viewModel.currentLyricsLineIndex) { newIndex in + guard !isPlainLyrics else { return } + guard newIndex >= 0 else { return } + withAnimation(.easeInOut(duration: 0.5)) { - if !isPlainLyrics { - proxy.scrollTo(newIndex, anchor: .center) - } + proxy.scrollTo(newIndex, anchor: .center) } } } diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 95b12e4..808abf2 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -618,9 +618,7 @@ class PlayerViewModel: ObservableObject { } if newIndex != currentLyricsLineIndex { - withAnimation(.easeInOut(duration: 0.1)) { - currentLyricsLineIndex = newIndex - } + currentLyricsLineIndex = newIndex } } From d033d5859c52fe193aa80ec6c5ed65d68b7737b2 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 03:04:14 +0700 Subject: [PATCH 27/43] feat: add button to close LyricsView --- flo/LyricsView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/flo/LyricsView.swift b/flo/LyricsView.swift index 0b2291a..d4a513f 100644 --- a/flo/LyricsView.swift +++ b/flo/LyricsView.swift @@ -54,6 +54,19 @@ struct LyricsView: View { .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) + + Button { + viewModel.toggleLyricsMode() + } label: { + Image(systemName: "chevron.down") + .font(.title3.weight(.semibold)) + .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(.white.opacity(0.15)) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3) + } } .padding(.horizontal, 30) .padding(.top, 16) From d827c478d5a8ae823b7124b3769c540578d24fac Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 03:17:06 +0700 Subject: [PATCH 28/43] docs(l10n): add another words --- flo/Resources/Localizable.xcstrings | 105 +++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 58c2e42..fa73919 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -191,8 +191,25 @@ } } }, + "Add/Change Custom" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tambah/Ubah Custom" + } + } + } + }, "Album Artist Only" : { - + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hanya Album Artist" + } + } + } }, "Album Info" : { "localizations" : { @@ -396,6 +413,16 @@ } } }, + "Disabled" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disabled" + } + } + } + }, "Download" : { "localizations" : { "en" : { @@ -606,6 +633,16 @@ } } }, + "https://lrclib.your-server.net" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://lrclib.your-server.net" + } + } + } + }, "Keychain.%@" : { "localizations" : { "id" : { @@ -616,6 +653,16 @@ } } }, + "Learn more at https://dub.sh/flo-lrclib" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selengkapnya di https://dub.sh/flo-lrclib" + } + } + } + }, "Library" : { "localizations" : { "en" : { @@ -760,6 +807,36 @@ } } }, + "LRCLIB" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB" + } + } + } + }, + "LRCLIB server is required. Learn more at dub.sh/flo-lrclib" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server LRCLIB dibutuhkan. Selengkapnya di dub.sh/flo-lrclib" + } + } + } + }, + "LRCLIB Server URL" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "LRCLIB Server URL" + } + } + } + }, "Max Bitrate" : { "localizations" : { "en" : { @@ -792,6 +869,16 @@ } } }, + "No lyrics available" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak ada lirik ditemukan" + } + } + } + }, "OK" : { "localizations" : { "en" : { @@ -1352,22 +1439,6 @@ } } }, - "Use translucent backgrounds" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Use translucent backgrounds" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gunakan translucent background" - } - } - } - }, "UserDefaults.%@" : { "localizations" : { "id" : { From 2dde9d1b91acb14230d5059ee9ba78a2897a9e04 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Wed, 4 Feb 2026 03:17:49 +0700 Subject: [PATCH 29/43] build(release): bump version to 2.0 (202) --- flo.xcodeproj/project.pbxproj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index c6dfc37..c22fa3c 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ C4E8D9632B763BAB00C2353E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C4E8D9622B763BAB00C2353E /* Preview Assets.xcassets */; }; C4E958982CA033BC00BBF394 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C4E958972CA033BC00BBF394 /* Localizable.xcstrings */; }; C4EAA4862C297E35007EB2E0 /* NowPlaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */; }; + C4F0B0A22F3A111100ABC002 /* AirPlayRoutePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */; }; C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F870CD2CEFCC5B00312F8A /* FloooService.swift */; }; C4F870D02CEFD25900312F8A /* StatCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F870CF2CEFD24D00312F8A /* StatCardView.swift */; }; C4FE524B2C14E1F70053763A /* UserDefaultsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FE524A2C14E1F70053763A /* UserDefaultsManager.swift */; }; @@ -137,6 +138,7 @@ C4E8D9622B763BAB00C2353E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; C4E958972CA033BC00BBF394 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlaying.swift; sourceTree = ""; }; + C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirPlayRoutePicker.swift; sourceTree = ""; }; C4F870CD2CEFCC5B00312F8A /* FloooService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloooService.swift; sourceTree = ""; }; C4F870CF2CEFD24D00312F8A /* StatCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatCardView.swift; sourceTree = ""; }; C4FE524A2C14E1F70053763A /* UserDefaultsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsManager.swift; sourceTree = ""; }; @@ -194,6 +196,7 @@ isa = PBXGroup; children = ( C456D8F92F2FF33B002AAB8B /* LRCParser.swift */, + C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */, C415F5592C11953000E3E1D2 /* Constants.swift */, C415F5632C11AA8700E3E1D2 /* Fonts.swift */, C49134522C15BE0C00CCF2EB /* Strings.swift */, @@ -424,6 +427,7 @@ C4100A6B2CE78B62001BC9BE /* Playlist.swift in Sources */, C4A4BF332C14437700363290 /* LibraryView.swift in Sources */, C46B8DD72CF4B89000B40644 /* Stats.swift in Sources */, + C4F0B0A22F3A111100ABC002 /* AirPlayRoutePicker.swift in Sources */, C415F5642C11AA8700E3E1D2 /* Fonts.swift in Sources */, C456D8FE2F300D3D002AAB8B /* LyricsView.swift in Sources */, C41E15152C0F95AD005BAE63 /* PlayerCustomSlider.swift in Sources */, @@ -590,7 +594,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 202; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; @@ -630,7 +634,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 202; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; From 1956dbd8321d168ef86eac14c49899ca2ebdb8ed Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 4 Feb 2026 18:55:13 +0100 Subject: [PATCH 30/43] feat: added web radios --- flo.xcodeproj/project.pbxproj | 24 ++++++++ flo/FloatingPlayerView.swift | 13 ++++- flo/Navigation/LibraryView.swift | 20 +++++++ flo/PlayerView.swift | 34 +++++++---- flo/PlayerViewModel.swift | 47 ++++++++++++++++ flo/Radios/RadiosView.swift | 78 ++++++++++++++++++++++++++ flo/Radios/RadiosViewModel.swift | 29 ++++++++++ flo/Resources/Localizable.xcstrings | 3 + flo/Shared/Models/Radio.swift | 71 +++++++++++++++++++++++ flo/Shared/Services/RadioService.swift | 25 +++++++++ flo/Shared/Utils/Constants.swift | 1 + 11 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 flo/Radios/RadiosView.swift create mode 100644 flo/Radios/RadiosViewModel.swift create mode 100644 flo/Shared/Models/Radio.swift create mode 100644 flo/Shared/Services/RadioService.swift diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index c22fa3c..1196b05 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + B0BAAAA62F31F0A0002A5FBB /* RadiosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */; }; + B0BAAAA92F3213AF002A5FBB /* RadiosViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */; }; + B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAA2F3214F7002A5FBB /* Radio.swift */; }; + B0BAAAAD2F3216A0002A5FBB /* RadioService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAC2F321697002A5FBB /* RadioService.swift */; }; C401D09A2C5AED9F009F91C7 /* LocalFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */; }; C4051DFF2CD25BBA0039D062 /* ArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */; }; C4100A692CE78B25001BC9BE /* PlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4100A682CE78B21001BC9BE /* PlaylistView.swift */; }; @@ -78,6 +82,10 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosView.swift; sourceTree = ""; }; + B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosViewModel.swift; sourceTree = ""; }; + B0BAAAAA2F3214F7002A5FBB /* Radio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radio.swift; sourceTree = ""; }; + B0BAAAAC2F321697002A5FBB /* RadioService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioService.swift; sourceTree = ""; }; C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileManager.swift; sourceTree = ""; }; C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistsView.swift; sourceTree = ""; }; C4100A682CE78B21001BC9BE /* PlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistView.swift; sourceTree = ""; }; @@ -162,6 +170,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + B0BAAAA72F32139D002A5FBB /* Radios */ = { + isa = PBXGroup; + children = ( + B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */, + B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */, + ); + path = Radios; + sourceTree = ""; + }; C4289F4B2C1253B800C3A4FD /* Shared */ = { isa = PBXGroup; children = ( @@ -185,6 +202,7 @@ C446A6B62C08DE8800CC9787 /* UserAuth.swift */, C4120FDC2C15E1C300E712BE /* Song.swift */, C49495802C1C25E5006B4D1E /* ScanStatus.swift */, + B0BAAAAA2F3214F7002A5FBB /* Radio.swift */, C4EAA4852C297E35007EB2E0 /* NowPlaying.swift */, C456D8F52F2FBD61002AAB8B /* LRCLIB.swift */, C456D8FB2F2FF397002AAB8B /* LyricsLine.swift */, @@ -237,6 +255,7 @@ C4DE89172C2FFBC900E078CC /* CoreDataManager.swift */, C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */, C4D7F84E2C7F2C5D00165EFD /* PlaybackService.swift */, + B0BAAAAC2F321697002A5FBB /* RadioService.swift */, C456D8F72F2FBD64002AAB8B /* LRCLIBService.swift */, ); path = Services; @@ -287,6 +306,7 @@ C415F54D2C11908100E3E1D2 /* AuthViewModel.swift */, C4289F472C12391300C3A4FD /* AlbumViewModel.swift */, C4289F502C139B2E00C3A4FD /* AlbumView.swift */, + B0BAAAA72F32139D002A5FBB /* Radios */, C4824D262CE908DA003EAB52 /* SongsView.swift */, C4A4BF3C2C1455A100363290 /* FloatingPlayerView.swift */, C42E7E172CE7EF4D00505B4E /* PlaylistDetailView.swift */, @@ -408,9 +428,12 @@ C4289F4A2C12392B00C3A4FD /* Album.swift in Sources */, C4824D252CE90872003EAB52 /* ArtistDetailView.swift in Sources */, C4E8D95E2B763BA900C2353E /* ContentView.swift in Sources */, + B0BAAAAD2F3216A0002A5FBB /* RadioService.swift in Sources */, C4FE524D2C14E71B0053763A /* KeychainManager.swift in Sources */, + B0BAAAA62F31F0A0002A5FBB /* RadiosView.swift in Sources */, C4875E022C149DDD00D9BAEB /* AuthService.swift in Sources */, C4EAA4862C297E35007EB2E0 /* NowPlaying.swift in Sources */, + B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */, C467AD552D329C8B00644E68 /* AccountLinkStatus.swift in Sources */, C4120FDD2C15E1C300E712BE /* Song.swift in Sources */, C467AD532D3267D000644E68 /* Subsonic.swift in Sources */, @@ -455,6 +478,7 @@ C429DB322D33C707009F2684 /* DownloadButtonView.swift in Sources */, C4824D232CE8C41F003EAB52 /* Playable.swift in Sources */, C41E15132C0F952A005BAE63 /* PlayerViewModel.swift in Sources */, + B0BAAAA92F3213AF002A5FBB /* RadiosViewModel.swift in Sources */, C4875E042C149F9A00D9BAEB /* APIManager.swift in Sources */, C4A4BF312C14433D00363290 /* HomeView.swift in Sources */, C4A4BF392C14445000363290 /* PreferencesView.swift in Sources */, diff --git a/flo/FloatingPlayerView.swift b/flo/FloatingPlayerView.swift index 4d06ce0..2e35895 100644 --- a/flo/FloatingPlayerView.swift +++ b/flo/FloatingPlayerView.swift @@ -61,10 +61,17 @@ struct FloatingPlayerView: View { .aspectRatio(contentMode: .fit) } else { LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image.resizable().aspectRatio(contentMode: .fit) - } else { + if state.isLoading { Color.gray.opacity(0.3) + } else { + if let image = state.image { + image.resizable().aspectRatio(contentMode: .fit) + } else { + Image("placeholder") + .resizable() + .scaledToFit() + .padding(8) + } } } } diff --git a/flo/Navigation/LibraryView.swift b/flo/Navigation/LibraryView.swift index 1bf8acf..80b6ace 100644 --- a/flo/Navigation/LibraryView.swift +++ b/flo/Navigation/LibraryView.swift @@ -125,6 +125,26 @@ struct LibraryView: View { } Divider() + + NavigationLink { + RadiosView() + .environmentObject(playerViewModel) + } label: { + HStack { + Image(systemName: "radio") + .frame(width: 20, height: 10) + .foregroundColor(.accent) + Text("Radios") + .customFont(.headline) + .padding(.leading, 8) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.caption) + }.padding(.horizontal).padding(.vertical, 5) + } + + Divider() } LazyVGrid(columns: columns) { diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 478ee74..7a41bcd 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -234,8 +234,8 @@ struct PlayerView: View { .padding(.top, 20) Spacer() - - if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { + let coverArtUrl = viewModel.getAlbumCoverArt() + if let image = UIImage(contentsOfFile: coverArtUrl) { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) @@ -244,21 +244,31 @@ struct PlayerView: View { RoundedRectangle(cornerRadius: 15, style: .continuous) ) } else { - LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSize, height: imageSize) - .clipShape( - RoundedRectangle(cornerRadius: 15, style: .continuous) - ) - } else { + LazyImage(url: URL(string: coverArtUrl)) { state in + if state.isLoading { Color.gray.opacity(0.3) .frame(width: imageSize, height: imageSize) .clipShape( RoundedRectangle(cornerRadius: 15, style: .continuous) ) + } else { + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } else if state.error != nil { + Image("placeholder") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape( + RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + } } } } diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 808abf2..1f72152 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -438,6 +438,53 @@ class PlayerViewModel: ObservableObject { self.addToQueue(idx: 0, item: queue) } + + func playRadioItem(radio: Radio) { + let item = radio.toPlayable() + let queue = PlaybackService.shared.addToQueue(item: item, isFromLocal: false) + + self.addToQueue(idx: 0, item: queue) + self.shouldHidePlayer = false + self.isLocallySaved = false + + self.resetLyrics() + + guard let radioUrl = URL(string: radio.streamUrl) else { + self.isMediaLoading = false + self.isMediaFailed = true + return + } + self.playerItem = AVPlayerItem(url: radioUrl) + self.player?.replaceCurrentItem(with: self.playerItem) + + self.playerItemObservation = self.playerItem?.publisher(for: \.status) + .sink { [weak self] status in + guard let self = self else { return } + switch status { + case .readyToPlay: + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.isMediaLoading = false + self.isMediaFailed = false + } + case .failed: + self.isMediaLoading = false + self.isMediaFailed = true + case .unknown: + self.isMediaLoading = false + @unknown default: + self.isMediaLoading = true + } + } + + self.play() + + self.initNowPlayingInfo( + title: item.name, + artist: item.artist, + playbackDuration: 0) + PlaybackService.shared.clearQueue() + UserDefaultsManager.removeObject(key: UserDefaultsKeys.nowPlayingProgress) + } func shuffleItem(item: T, isFromLocal: Bool) { var shuffledItem = item diff --git a/flo/Radios/RadiosView.swift b/flo/Radios/RadiosView.swift new file mode 100644 index 0000000..13d03f8 --- /dev/null +++ b/flo/Radios/RadiosView.swift @@ -0,0 +1,78 @@ + +// +// SongsView.swift +// flo +// +// + +import NukeUI +import SwiftUI + +struct RadiosView: View { + @EnvironmentObject private var playerViewModel: PlayerViewModel + + @StateObject var viewModel = RadiosViewModel() + @State private var searchRadio = "" + + var filteredRadios: [Radio] { + if searchRadio.isEmpty { + return viewModel.radios + } else { + return viewModel.radios.filter { radio in + radio.name.localizedCaseInsensitiveContains(searchRadio) + } + } + } + + var body: some View { + ScrollView { + LazyVStack { + ForEach(filteredRadios, id: \.id) { radio in + Group { + HStack { + Color("PlayerColor").frame(width: 40, height: 40) + .cornerRadius(5) + .overlay { + Image(systemName: "dot.radiowaves.up.forward") + .resizable() + .scaledToFit() + .foregroundStyle(.white) + .padding(8) + } + + VStack(alignment: .leading) { + Text(radio.name) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + .padding(.bottom, 3) + } + .padding(.horizontal, 10) + + Spacer() + } + .padding(.horizontal) + .background(Color(UIColor.systemBackground)) + + Divider() + } + .onTapGesture { + playerViewModel.playRadioItem(radio: radio) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 10) + } + .navigationTitle("Radios") + .navigationBarTitleDisplayMode(.large) + .searchable( + text: $searchRadio, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search" + ) + .onAppear { + viewModel.fetchAllRadios() + } + } +} diff --git a/flo/Radios/RadiosViewModel.swift b/flo/Radios/RadiosViewModel.swift new file mode 100644 index 0000000..451da23 --- /dev/null +++ b/flo/Radios/RadiosViewModel.swift @@ -0,0 +1,29 @@ +// flo + +import Foundation +import Combine + +class RadiosViewModel: ObservableObject { + @Published var radios: [Radio] = [] + + @Published var isLoading = false + @Published var error: Error? + + func fetchAllRadios() { + RadioService.shared.getAllRadios { result in + self.isLoading = true + + DispatchQueue.main.async { + self.isLoading = false + + switch result { + case .success(let radios): + self.radios = radios + + case .failure(let error): + self.error = error + } + } + } + } +} diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index fa73919..552112b 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -1006,6 +1006,9 @@ } } } + }, + "Radios" : { + }, "Redownload Album" : { "localizations" : { diff --git a/flo/Shared/Models/Radio.swift b/flo/Shared/Models/Radio.swift new file mode 100644 index 0000000..9d20495 --- /dev/null +++ b/flo/Shared/Models/Radio.swift @@ -0,0 +1,71 @@ +// +// Radio.swift +// flo +// +// + +import Foundation + +struct RadioList: SubsonicResponseData { + static var key: String { + return "internetRadioStations" + } + let internetRadioStation: [Radio] +} + +struct RadioListResponse: Codable { + let subsonicResponse: SubsonicResponse + + private enum CodingKeys: String, CodingKey { + case subsonicResponse = "subsonic-response" + } + var radioStations: [Radio] { + return subsonicResponse.data?.internetRadioStation ?? [] + } +} + +struct Radio: Codable, Identifiable, Hashable { + let id: String + let name: String + let streamUrl: String + + enum CodingKeys: CodingKey { + case id + case name + case streamUrl + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(String.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.streamUrl = try container.decode(String.self, forKey: .streamUrl) + } + + init( + id: String, + name: String, + streamUrl: String + ) { + self.id = id + self.name = name + self.streamUrl = streamUrl + } + + // This function will create a mock 'Playable' entity for the radio station + func toPlayable() -> RadioEntity { + return RadioEntity(id: id, name: name, songs: [Song(id: id, title: name, albumId: "", albumName: "", artist: streamUrl, trackNumber: 1, discNumber: 1, bitRate: .zero, sampleRate: 1, suffix: "", duration: .infinity, mediaFileId: id)], artist: streamUrl) + } + +} + +struct RadioEntity: Playable { + var id: String + + var name: String + + var songs: [Song] + + var artist: String +} diff --git a/flo/Shared/Services/RadioService.swift b/flo/Shared/Services/RadioService.swift new file mode 100644 index 0000000..2f4dbd6 --- /dev/null +++ b/flo/Shared/Services/RadioService.swift @@ -0,0 +1,25 @@ +// flo + +import Foundation +import Alamofire + +class RadioService { + static let shared = RadioService() + + func getStreamUrl(radio: Radio) -> String { + radio.streamUrl + } + + func getAllRadios(completion: @escaping (Result<[Radio], Error>) -> Void) { + + APIManager.shared.SubsonicEndpointRequest(endpoint: API.SubsonicEndpoint.radios, parameters: nil) { + (response: DataResponse) in + switch response.result { + case .success(let radios): + completion(.success(radios.radioStations)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index 3420a0c..e004b9f 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -28,6 +28,7 @@ struct API { static let scanStatus = "/rest/getScanStatus" static let download = "/rest/download" static let scrobble = "/rest/scrobble" + static let radios = "/rest/getInternetRadioStations" } } From 0388d17352cd66c3fd81705e57eb8c59e24660f0 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:04:01 +0700 Subject: [PATCH 31/43] feat: playerView for radios --- flo/PlayerView.swift | 64 +++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 7a41bcd..136ff1a 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -169,6 +169,11 @@ struct PlayerView: View { } } .frame(maxHeight: .infinity) + .onChange(of: viewModel.isLiveRadio) { isLive in + if isLive { + showQueue = false + } + } .background { ZStack { if UserDefaultsManager.playerBackground == PlayerBackground.translucent { @@ -318,15 +323,19 @@ struct PlayerView: View { Spacer() VStack { - PlayerCustomSlider( - isMediaLoading: viewModel.isMediaLoading, - isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 - ) { newValue in - viewModel.seek(to: newValue) + if viewModel.isLiveRadio { + liveProgressBar() + } else { + PlayerCustomSlider( + isMediaLoading: viewModel.isMediaLoading, + isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 + ) { newValue in + viewModel.seek(to: newValue) + } } HStack { - Text(viewModel.currentTimeString) + Text(viewModel.isLiveRadio ? "" : viewModel.currentTimeString) .foregroundColor(.white) .customFont(.caption2) .frame(width: 60, alignment: .leading) @@ -334,9 +343,11 @@ struct PlayerView: View { Spacer() Text( - viewModel.isPlayFromSource - ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" - : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)" + viewModel.isLiveRadio + ? "LIVE" + : (viewModel.isPlayFromSource + ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" + : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)") ) .foregroundColor(.white) .customFont(.caption2) @@ -346,7 +357,7 @@ struct PlayerView: View { Spacer() - Text(viewModel.totalTimeString) + Text(viewModel.isLiveRadio ? "" : viewModel.totalTimeString) .foregroundColor(.white) .customFont(.caption2) .frame(width: 60, alignment: .trailing) @@ -362,17 +373,20 @@ struct PlayerView: View { @ViewBuilder private func bottomControlBar(showQueue: Binding) -> some View { + let isLyricsDisabled = + viewModel.isLiveRadio || (viewModel.lyrics.isEmpty && (viewModel.lyricsError != nil)) + + let isQueueDisabled = viewModel.isLiveRadio + HStack(spacing: 0) { Button { viewModel.toggleLyricsMode() } label: { Image(systemName: "quote.bubble") .font(.title2) - .foregroundColor( - viewModel.lyrics.isEmpty && (viewModel.lyricsError != nil) - ? .white.opacity(0.4) : .white - ) + .foregroundColor(isLyricsDisabled ? .white.opacity(0.4) : .white) } + .disabled(isLyricsDisabled) .frame(width: 56, alignment: .leading) AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) @@ -393,7 +407,9 @@ struct PlayerView: View { } Button { - showQueue.wrappedValue.toggle() + if !isQueueDisabled { + showQueue.wrappedValue.toggle() + } } label: { Image(systemName: "list.bullet") .font(.title2) @@ -419,9 +435,27 @@ struct PlayerView: View { .offset(x: 10, y: -10) ) } + .disabled(isQueueDisabled) + .opacity(isQueueDisabled ? 0.4 : 1) .frame(width: 56, alignment: .trailing) } } + + @ViewBuilder + private func liveProgressBar() -> some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.gray.opacity(0.8)) + .frame(height: 5) + + Capsule() + .fill(Color.white) + .frame(width: geometry.size.width, height: 4) + } + } + .frame(height: 20) + } } struct PlayerView_previews: PreviewProvider { From 90e5c0176ec2fae780e1617279d38d9b33dbf020 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:04:38 +0700 Subject: [PATCH 32/43] feat: some enhacnement on playing radios --- flo/PlayerViewModel.swift | 103 ++++++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 1f72152..5d480ff 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -64,6 +64,12 @@ class PlayerViewModel: ObservableObject { return UserDefaultsManager.LRCLIBServerURL != "" } + var isLiveRadio: Bool { + guard hasNowPlaying() else { return false } + + return nowPlaying.duration.isInfinite || nowPlaying.duration.isNaN + } + init() { self.player = AVPlayer() self.observeInterruptionNotifications() @@ -240,7 +246,7 @@ class PlayerViewModel: ObservableObject { FloooViewModel.shared.setNowPlayingToScrobbleServer(nowPlaying: self.nowPlaying) - if isLRCLIBEnabled { + if isLRCLIBEnabled && !isLiveRadio { self.fetchLyrics() } } @@ -255,7 +261,12 @@ class PlayerViewModel: ObservableObject { let currentTime = CMTimeGetSeconds(time) let roundedTotalDuration = floor(self.totalDuration) - self.progress = currentTime / self.totalDuration + if self.totalDuration.isFinite, self.totalDuration > 0 { + self.progress = currentTime / self.totalDuration + } else { + self.progress = 0.0 + } + self.currentTimeString = timeString(for: currentTime) UserDefaultsManager.nowPlayingProgress = self.progress @@ -272,7 +283,10 @@ class PlayerViewModel: ObservableObject { } } - if round(currentTime) >= roundedTotalDuration { + if self.totalDuration.isFinite, + self.totalDuration > 0, + round(currentTime) >= roundedTotalDuration + { self.nextSong() UserDefaultsManager.removeObject(key: UserDefaultsKeys.nowPlayingProgress) @@ -362,6 +376,10 @@ class PlayerViewModel: ObservableObject { commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.addTarget { event in + if self.isLiveRadio { + return .commandFailed + } + if let event = event as? MPChangePlaybackPositionCommandEvent { let progress = event.positionTime / self.totalDuration @@ -403,6 +421,10 @@ class PlayerViewModel: ObservableObject { } func seek(to progress: Double) { + if isLiveRadio { + return + } + let newTime = CMTime( seconds: progress * totalDuration, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) @@ -438,25 +460,32 @@ class PlayerViewModel: ObservableObject { self.addToQueue(idx: 0, item: queue) } - + func playRadioItem(radio: Radio) { + guard let radioUrl = Self.normalizedRadioURL(from: radio.streamUrl) else { + return + } + let item = radio.toPlayable() let queue = PlaybackService.shared.addToQueue(item: item, isFromLocal: false) - - self.addToQueue(idx: 0, item: queue) + + self.activeQueueIdx = 0 + self.queue = queue self.shouldHidePlayer = false self.isLocallySaved = false - + self._playFromLocal = false + self.resetLyrics() - - guard let radioUrl = URL(string: radio.streamUrl) else { - self.isMediaLoading = false - self.isMediaFailed = true - return + + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + + self.timeObserverToken = nil } + self.playerItem = AVPlayerItem(url: radioUrl) self.player?.replaceCurrentItem(with: self.playerItem) - + self.playerItemObservation = self.playerItem?.publisher(for: \.status) .sink { [weak self] status in guard let self = self else { return } @@ -475,9 +504,17 @@ class PlayerViewModel: ObservableObject { self.isMediaLoading = true } } - + + self.isMediaLoading = true + self.isMediaFailed = false + self.totalDuration = self.nowPlaying.duration + self.progress = 0.0 + self.currentTimeString = "00:00" + self.totalTimeString = "00:00" + + self.addPeriodicTimeObserver() self.play() - + self.initNowPlayingInfo( title: item.name, artist: item.artist, @@ -670,11 +707,47 @@ class PlayerViewModel: ObservableObject { } func toggleLyricsMode() { + if isLiveRadio { + return + } + withAnimation(.spring(duration: 0.3)) { isLyricsMode.toggle() } } + private static func normalizedRadioURL(from streamUrl: String) -> URL? { + let trimmedUrl = streamUrl.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedUrl.isEmpty else { return nil } + + if let url = URL(string: trimmedUrl), url.scheme != nil { + return url + } + + if let encoded = trimmedUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed), + let url = URL(string: encoded), + url.scheme != nil + { + return url + } + + let withScheme = "https://\(trimmedUrl)" + + if let url = URL(string: withScheme), url.host != nil { + return url + } + + if let encoded = withScheme.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed), + let url = URL(string: encoded), + url.host != nil + { + return url + } + + return nil + } + deinit { if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) From 28fabed337d3d4146d606003bb08831720494753 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:05:14 +0700 Subject: [PATCH 33/43] feat: add search and empty state on RadiosView --- flo/Radios/RadiosView.swift | 96 +++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/flo/Radios/RadiosView.swift b/flo/Radios/RadiosView.swift index 13d03f8..c72789c 100644 --- a/flo/Radios/RadiosView.swift +++ b/flo/Radios/RadiosView.swift @@ -1,4 +1,3 @@ - // // SongsView.swift // flo @@ -10,10 +9,10 @@ import SwiftUI struct RadiosView: View { @EnvironmentObject private var playerViewModel: PlayerViewModel - + @StateObject var viewModel = RadiosViewModel() @State private var searchRadio = "" - + var filteredRadios: [Radio] { if searchRadio.isEmpty { return viewModel.radios @@ -23,46 +22,50 @@ struct RadiosView: View { } } } - + var body: some View { ScrollView { - LazyVStack { - ForEach(filteredRadios, id: \.id) { radio in - Group { - HStack { - Color("PlayerColor").frame(width: 40, height: 40) - .cornerRadius(5) - .overlay { - Image(systemName: "dot.radiowaves.up.forward") - .resizable() - .scaledToFit() - .foregroundStyle(.white) - .padding(8) + if filteredRadios.isEmpty { + emptyStateView + } else { + LazyVStack { + ForEach(filteredRadios, id: \.id) { radio in + Group { + HStack { + Color("PlayerColor").frame(width: 40, height: 40) + .cornerRadius(5) + .overlay { + Image(systemName: "dot.radiowaves.up.forward") + .resizable() + .scaledToFit() + .foregroundStyle(.white) + .padding(8) + } + + VStack(alignment: .leading) { + Text(radio.name) + .customFont(.headline) + .multilineTextAlignment(.leading) + .lineLimit(2) + .padding(.bottom, 3) } - - VStack(alignment: .leading) { - Text(radio.name) - .customFont(.headline) - .multilineTextAlignment(.leading) - .lineLimit(2) - .padding(.bottom, 3) + .padding(.horizontal, 10) + + Spacer() } - .padding(.horizontal, 10) - - Spacer() + .padding(.horizontal) + .background(Color(UIColor.systemBackground)) + + Divider() } - .padding(.horizontal) - .background(Color(UIColor.systemBackground)) - - Divider() - } - .onTapGesture { - playerViewModel.playRadioItem(radio: radio) + .onTapGesture { + playerViewModel.playRadioItem(radio: radio) + } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) } + .padding(.top, 10) } - .padding(.top, 10) } .navigationTitle("Radios") .navigationBarTitleDisplayMode(.large) @@ -75,4 +78,27 @@ struct RadiosView: View { viewModel.fetchAllRadios() } } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Image(systemName: "dot.radiowaves.up.forward") + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(Color.gray.opacity(0.7)) + + Text(searchRadio.isEmpty ? "No radios available" : "No radios match your search") + .customFont(.headline) + .multilineTextAlignment(.center) + + Text( + searchRadio.isEmpty + ? "Add radios in your Navidrome server." + : "Try a different keyword." + ) + .customFont(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding(.horizontal, 24) + } } From 7a64dd5d34f919f891afc36ae054da0a38eed9b5 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:05:38 +0700 Subject: [PATCH 34/43] feat: utils to show radio source also some formatting by swift-lint --- flo/Shared/Models/Radio.swift | 44 +++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/flo/Shared/Models/Radio.swift b/flo/Shared/Models/Radio.swift index 9d20495..7949727 100644 --- a/flo/Shared/Models/Radio.swift +++ b/flo/Shared/Models/Radio.swift @@ -2,6 +2,7 @@ // Radio.swift // flo // +// Created by Francesco (f-longobardi) // import Foundation @@ -15,7 +16,7 @@ struct RadioList: SubsonicResponseData { struct RadioListResponse: Codable { let subsonicResponse: SubsonicResponse - + private enum CodingKeys: String, CodingKey { case subsonicResponse = "subsonic-response" } @@ -52,20 +53,49 @@ struct Radio: Codable, Identifiable, Hashable { self.name = name self.streamUrl = streamUrl } - + // This function will create a mock 'Playable' entity for the radio station func toPlayable() -> RadioEntity { - return RadioEntity(id: id, name: name, songs: [Song(id: id, title: name, albumId: "", albumName: "", artist: streamUrl, trackNumber: 1, discNumber: 1, bitRate: .zero, sampleRate: 1, suffix: "", duration: .infinity, mediaFileId: id)], artist: streamUrl) + let displayHost = Radio.displayHost(from: streamUrl) + return RadioEntity( + id: id, + name: name, + songs: [ + Song( + id: id, + title: name, + albumId: "", + albumName: "", + artist: displayHost, + trackNumber: 1, + discNumber: 1, + bitRate: .zero, + sampleRate: 1, + suffix: "", + duration: .infinity, + mediaFileId: id + ) + ], + artist: displayHost + ) + } + + private static func displayHost(from urlString: String) -> String { + guard let url = URL(string: urlString), + let host = url.host, + !host.isEmpty + else { + return urlString + } + + return host } - + } struct RadioEntity: Playable { var id: String - var name: String - var songs: [Song] - var artist: String } From c1a2e999937547abc396c68f0daf054f18c4c50a Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:06:14 +0700 Subject: [PATCH 35/43] fix: prevent some crashes on edge cases on lyrics --- flo/Shared/Services/LRCLIBService.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flo/Shared/Services/LRCLIBService.swift b/flo/Shared/Services/LRCLIBService.swift index 7d1d1dd..26e63ff 100644 --- a/flo/Shared/Services/LRCLIBService.swift +++ b/flo/Shared/Services/LRCLIBService.swift @@ -27,7 +27,11 @@ class LRCLIBService { parameters["album_name"] = albumName } - if let duration = duration { + if let duration = duration, + duration.isFinite, + duration > 0, + duration < Double(Int.max) + { parameters["duration"] = String(Int(duration.rounded())) } From d00d14ce6f3ee4f90777c7f1bf124ce58a525ab1 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:08:16 +0700 Subject: [PATCH 36/43] docs(l10n): add another words --- flo/Resources/Localizable.xcstrings | 59 ++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 552112b..2da47ed 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -191,6 +191,16 @@ } } }, + "Add radios in your Navidrome server." : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tambahkan Radio melalui server Navidrome" + } + } + } + }, "Add/Change Custom" : { "localizations" : { "id" : { @@ -711,6 +721,16 @@ } } }, + "LIVE" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + } + } + }, "Local Storage" : { "localizations" : { "en" : { @@ -879,6 +899,26 @@ } } }, + "No radios available" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak ada radio ditemukan" + } + } + } + }, + "No radios match your search" : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak ada radio ditemukan" + } + } + } + }, "OK" : { "localizations" : { "en" : { @@ -1008,7 +1048,14 @@ } }, "Radios" : { - + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios" + } + } + } }, "Redownload Album" : { "localizations" : { @@ -1442,6 +1489,16 @@ } } }, + "Try a different keyword." : { + "localizations" : { + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coba kata kunci yang lain." + } + } + } + }, "UserDefaults.%@" : { "localizations" : { "id" : { From 9f2d933a21e8ad22fb736e750007a201d178797c Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:31:51 +0700 Subject: [PATCH 37/43] feat: add placeholder album cover for radios on MP --- flo/PlayerViewModel.swift | 61 +++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/flo/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 5d480ff..baf1738 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -297,39 +297,56 @@ class PlayerViewModel: ObservableObject { private func initNowPlayingInfo( title: String, artist: String, playbackDuration: Double ) { - var nowPlayingInfo = [String: Any]() - DispatchQueue.global().async { - let url: URL - let albumCoverArt = self.getAlbumCoverArt() + let artwork = self.makeNowPlayingArtwork() - if albumCoverArt.hasPrefix("/") { - url = URL(fileURLWithPath: albumCoverArt) - } else { - guard let remoteURL = URL(string: albumCoverArt) else { - return + DispatchQueue.main.async { + var nowPlayingInfo = [String: Any]() + + nowPlayingInfo[MPMediaItemPropertyTitle] = title + nowPlayingInfo[MPMediaItemPropertyArtist] = artist + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = playbackDuration + + if let artwork = artwork { + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork } - url = remoteURL + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } + } + } - if let data = try? Data(contentsOf: url), - let image = UIImage(data: data) - { - let artwork = MPMediaItemArtwork(boundsSize: image.size) { size in + private func makeNowPlayingArtwork() -> MPMediaItemArtwork? { + if isLiveRadio { + if let image = UIImage(named: "placeholder") { + return MPMediaItemArtwork(boundsSize: image.size) { _ in return image } - - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork } - DispatchQueue.main.async { - nowPlayingInfo[MPMediaItemPropertyTitle] = title - nowPlayingInfo[MPMediaItemPropertyArtist] = artist - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = playbackDuration + return nil + } - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - } + let albumCoverArt = self.getAlbumCoverArt() + + let image: UIImage? + + if albumCoverArt.hasPrefix("/") { + image = UIImage(contentsOfFile: albumCoverArt) + } else if let remoteURL = URL(string: albumCoverArt), + let data = try? Data(contentsOf: remoteURL) + { + image = UIImage(data: data) + } else { + image = nil + } + + guard let resolvedImage = image else { + return nil + } + + return MPMediaItemArtwork(boundsSize: resolvedImage.size) { _ in + return resolvedImage } } From 3260d025b5f049ca17f1d65677e2bf26d3fe8061 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:32:41 +0700 Subject: [PATCH 38/43] feat: remove backward and forward button on radios --- flo/PlayerView.swift | 51 +++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/flo/PlayerView.swift b/flo/PlayerView.swift index 136ff1a..224a82b 100644 --- a/flo/PlayerView.swift +++ b/flo/PlayerView.swift @@ -297,26 +297,43 @@ struct PlayerView: View { Spacer() - HStack(spacing: size.width * 0.15) { - Button { - viewModel.prevSong() - } label: { - Image(systemName: "backward.fill").font(.title) - } + if viewModel.isLiveRadio { + HStack { + Spacer() - Button { - viewModel.isPlaying ? viewModel.pause() : viewModel.play() - } label: { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 50)) + Button { + viewModel.isPlaying ? viewModel.pause() : viewModel.play() + } label: { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 50)) + } + .foregroundColor(viewModel.isMediaLoading ? .gray : .white) + .disabled(viewModel.isMediaLoading) + + Spacer() } - .foregroundColor(viewModel.isMediaLoading ? .gray : .white) - .disabled(viewModel.isMediaLoading) + } else { + HStack(spacing: size.width * 0.15) { + Button { + viewModel.prevSong() + } label: { + Image(systemName: "backward.fill").font(.title) + } - Button { - viewModel.nextSong() - } label: { - Image(systemName: "forward.fill").font(.title) + Button { + viewModel.isPlaying ? viewModel.pause() : viewModel.play() + } label: { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 50)) + } + .foregroundColor(viewModel.isMediaLoading ? .gray : .white) + .disabled(viewModel.isMediaLoading) + + Button { + viewModel.nextSong() + } label: { + Image(systemName: "forward.fill").font(.title) + } } } From 1b497c356d0b25a4edd23b2368a157ea037c18f4 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 01:33:34 +0700 Subject: [PATCH 39/43] build(release): bump version to 2.0 (203) --- flo.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 1196b05..47b055e 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -618,7 +618,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; @@ -658,7 +658,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; From 907dfe7ecb02d75bc5852cf49e64a1a4718e9325 Mon Sep 17 00:00:00 2001 From: Francesco Date: Fri, 6 Feb 2026 19:17:02 +0100 Subject: [PATCH 40/43] fix: crash on smaller screen + scale factor for strings in stat card --- flo.xcodeproj/project.pbxproj | 4 ++++ flo/Navigation/HomeView.swift | 6 ++++-- flo/Shared/Utils/UIScreen+.swift | 10 ++++++++++ flo/StatCardView.swift | 6 ++++++ 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 flo/Shared/Utils/UIScreen+.swift diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 47b055e..5125ede 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ B0BAAAA92F3213AF002A5FBB /* RadiosViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */; }; B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAA2F3214F7002A5FBB /* Radio.swift */; }; B0BAAAAD2F3216A0002A5FBB /* RadioService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAC2F321697002A5FBB /* RadioService.swift */; }; + B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; C401D09A2C5AED9F009F91C7 /* LocalFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */; }; C4051DFF2CD25BBA0039D062 /* ArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */; }; C4100A692CE78B25001BC9BE /* PlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4100A682CE78B21001BC9BE /* PlaylistView.swift */; }; @@ -86,6 +87,7 @@ B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosViewModel.swift; sourceTree = ""; }; B0BAAAAA2F3214F7002A5FBB /* Radio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radio.swift; sourceTree = ""; }; B0BAAAAC2F321697002A5FBB /* RadioService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioService.swift; sourceTree = ""; }; + B02A003E2F3666240024E8EC /* UIScreen+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+.swift"; sourceTree = ""; }; C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileManager.swift; sourceTree = ""; }; C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistsView.swift; sourceTree = ""; }; C4100A682CE78B21001BC9BE /* PlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistView.swift; sourceTree = ""; }; @@ -213,6 +215,7 @@ C4289F4D2C1253EB00C3A4FD /* Utils */ = { isa = PBXGroup; children = ( + B02A003E2F3666240024E8EC /* UIScreen+.swift */, C456D8F92F2FF33B002AAB8B /* LRCParser.swift */, C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */, C415F5592C11953000E3E1D2 /* Constants.swift */, @@ -450,6 +453,7 @@ C4100A6B2CE78B62001BC9BE /* Playlist.swift in Sources */, C4A4BF332C14437700363290 /* LibraryView.swift in Sources */, C46B8DD72CF4B89000B40644 /* Stats.swift in Sources */, + B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */, C4F0B0A22F3A111100ABC002 /* AirPlayRoutePicker.swift in Sources */, C415F5642C11AA8700E3E1D2 /* Fonts.swift in Sources */, C456D8FE2F300D3D002AAB8B /* LyricsView.swift in Sources */, diff --git a/flo/Navigation/HomeView.swift b/flo/Navigation/HomeView.swift index 5af6def..84d757b 100644 --- a/flo/Navigation/HomeView.swift +++ b/flo/Navigation/HomeView.swift @@ -105,8 +105,10 @@ struct HomeView: View { VStack(alignment: .leading, spacing: 16) { Text("Listening Activity (all time)").customFont(.title2).fontWeight(.bold) .multilineTextAlignment(.leading) - - EqualHeightHStack(alignment: .top, spacing: 16) { + + let statCardSpacing: CGFloat = UIScreen.screenWidth <= 375 ? 8 : 16 + + EqualHeightHStack(alignment: .top, spacing: statCardSpacing) { EqualHeightItem { StatCard( title: "Total Listens", diff --git a/flo/Shared/Utils/UIScreen+.swift b/flo/Shared/Utils/UIScreen+.swift new file mode 100644 index 0000000..859813e --- /dev/null +++ b/flo/Shared/Utils/UIScreen+.swift @@ -0,0 +1,10 @@ +// flo + +import SwiftUI + +extension UIScreen { + static let screenWidth = UIScreen.main.bounds.size.width + static let screenHeight = UIScreen.main.bounds.size.height + static let screenSize = UIScreen.main.bounds.size +} + diff --git a/flo/StatCardView.swift b/flo/StatCardView.swift index ce2d0f7..18be8c4 100644 --- a/flo/StatCardView.swift +++ b/flo/StatCardView.swift @@ -43,6 +43,7 @@ struct StatCard: View { Text(title) .foregroundColor(.secondary) .customFont(.body) + .minimumScaleFactor(0.7) Spacer() @@ -60,6 +61,7 @@ struct StatCard: View { .lineSpacing(2) .fontWeight(.bold) .lineLimit(2) + .minimumScaleFactor(0.7) if let subtitle = subtitle { Text(subtitle) @@ -67,6 +69,7 @@ struct StatCard: View { .customFont(.subheadline) .lineSpacing(2) .lineLimit(2) + .minimumScaleFactor(0.7) } } } @@ -81,6 +84,7 @@ struct StatCard: View { Text(title) .foregroundColor(.secondary) .customFont(.body) + .minimumScaleFactor(0.7) Spacer() @@ -98,6 +102,7 @@ struct StatCard: View { .lineSpacing(2) .fontWeight(.bold) .lineLimit(2) + .minimumScaleFactor(0.7) if let subtitle = subtitle { Text(subtitle) @@ -105,6 +110,7 @@ struct StatCard: View { .customFont(.subheadline) .lineSpacing(2) .lineLimit(2) + .minimumScaleFactor(0.7) } } } From 52bc16e1745e8f3eb6fde2dcb7f89d6341488ad3 Mon Sep 17 00:00:00 2001 From: Francesco Date: Fri, 6 Feb 2026 20:06:50 +0100 Subject: [PATCH 41/43] fix: bottom floating view for smaller screen --- flo/ContentView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flo/ContentView.swift b/flo/ContentView.swift index 1816c10..c434947 100644 --- a/flo/ContentView.swift +++ b/flo/ContentView.swift @@ -74,6 +74,7 @@ struct ContentView: View { Spacer() if playerViewModel.hasNowPlaying() && !playerViewModel.shouldHidePlayer { + let bottomPaddingForSmallerScreens: CGFloat = UIScreen.screenWidth <= 375 ? 32 : 0 FloatingPlayerView(viewModel: playerViewModel) .padding(.bottom, 50) .opacity(playerViewModel.hasNowPlaying() ? 1 : 0) @@ -106,6 +107,7 @@ struct ContentView: View { self.isSwipping = false } ) + .padding(.bottom, bottomPaddingForSmallerScreens) } } } From d15ad2cab222ab106c6658f89e8f094c2b59ff00 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 18:49:04 +0700 Subject: [PATCH 42/43] style: add header to UIScreen+ utility --- flo/Shared/Utils/UIScreen+.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flo/Shared/Utils/UIScreen+.swift b/flo/Shared/Utils/UIScreen+.swift index 859813e..dfb0949 100644 --- a/flo/Shared/Utils/UIScreen+.swift +++ b/flo/Shared/Utils/UIScreen+.swift @@ -1,4 +1,9 @@ -// flo +// +// UIScreen+.swift +// flo +// +// Created by Francesco on 06/02/26. +// import SwiftUI @@ -7,4 +12,3 @@ extension UIScreen { static let screenHeight = UIScreen.main.bounds.size.height static let screenSize = UIScreen.main.bounds.size } - From bcca8c7ddc243c00da353363e0a3a69ff2368360 Mon Sep 17 00:00:00 2001 From: rizaldy Date: Sun, 8 Feb 2026 18:59:08 +0700 Subject: [PATCH 43/43] build(release): bump version to 2.0 (204) --- flo.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 5125ede..80cb354 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -7,11 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; B0BAAAA62F31F0A0002A5FBB /* RadiosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */; }; B0BAAAA92F3213AF002A5FBB /* RadiosViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */; }; B0BAAAAB2F3214F7002A5FBB /* Radio.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAA2F3214F7002A5FBB /* Radio.swift */; }; B0BAAAAD2F3216A0002A5FBB /* RadioService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BAAAAC2F321697002A5FBB /* RadioService.swift */; }; - B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; C401D09A2C5AED9F009F91C7 /* LocalFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */; }; C4051DFF2CD25BBA0039D062 /* ArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */; }; C4100A692CE78B25001BC9BE /* PlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4100A682CE78B21001BC9BE /* PlaylistView.swift */; }; @@ -83,11 +83,11 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + B02A003E2F3666240024E8EC /* UIScreen+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+.swift"; sourceTree = ""; }; B0BAAAA52F31F0A0002A5FBB /* RadiosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosView.swift; sourceTree = ""; }; B0BAAAA82F3213AB002A5FBB /* RadiosViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiosViewModel.swift; sourceTree = ""; }; B0BAAAAA2F3214F7002A5FBB /* Radio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radio.swift; sourceTree = ""; }; B0BAAAAC2F321697002A5FBB /* RadioService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioService.swift; sourceTree = ""; }; - B02A003E2F3666240024E8EC /* UIScreen+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+.swift"; sourceTree = ""; }; C401D0992C5AED9F009F91C7 /* LocalFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileManager.swift; sourceTree = ""; }; C4051DFE2CD25BBA0039D062 /* ArtistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistsView.swift; sourceTree = ""; }; C4100A682CE78B21001BC9BE /* PlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistView.swift; sourceTree = ""; }; @@ -622,7 +622,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P; @@ -662,7 +662,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 204; DEVELOPMENT_ASSET_PATHS = "\"flo/Resources/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 8BJ4LW5J8P;