Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
3347720
feat: update statcard to support ios 26
faultables Nov 8, 2025
69f4346
feat: add @preconcurrency import CoreData
faultables Nov 8, 2025
82bce84
feat: add FloatingPlayerGlassView
faultables Nov 8, 2025
866e2d5
refactor: remove FloatingPlayerGlassView
faultables Feb 1, 2026
b06ee82
chore: remove unused words
faultables Feb 1, 2026
9b71571
feat: change online status ui
faultables Feb 1, 2026
c88aad0
feat: add indicator for .freshInstall too
faultables Feb 1, 2026
54c924a
feat: new FloatingPlayerView style
faultables Feb 1, 2026
c5c3ac0
added filter in artist view for only album artist
f-longobardi Nov 26, 2025
7086798
perf: use `LazyVStack` for music queue
r1sim Sep 13, 2025
1e5c20d
perf: use batch api in addToQueue
r1sim Sep 13, 2025
485e735
feat: enable lrclib integration
faultables Feb 1, 2026
cbcfa0d
feat: add externalRequest to APIManager extension
faultables Feb 1, 2026
8e604e6
feat: API to manage lyrics display
faultables Feb 1, 2026
96b1a3c
fix: dont check on resetLyrics
faultables Feb 1, 2026
7fd8fbb
feat: integrate with LyricsView
faultables Feb 1, 2026
a1e20f6
feat: create LyricsView UI
faultables Feb 1, 2026
9044a34
feat: add lrclib to experimental features
faultables Feb 1, 2026
3253bcf
feat: wrap sections on VStack
faultables Feb 1, 2026
319b6b9
feat: remove translucent backgrounds setting
faultables Feb 1, 2026
4ff88b2
build(release): bump version to 2.0
faultables Feb 1, 2026
62de9ca
feat: make lyrics work with playlists too
faultables Feb 3, 2026
fdd5fc7
feat: make height on stats card equal
faultables Feb 3, 2026
eb03229
feat: implement airplay route picker
faultables Feb 3, 2026
aff3f89
feat: add spacing too VStack
faultables Feb 3, 2026
0903e31
fix: jitter on lyric change in some conditions
faultables Feb 3, 2026
d033d58
feat: add button to close LyricsView
faultables Feb 3, 2026
d827c47
docs(l10n): add another words
faultables Feb 3, 2026
2dde9d1
build(release): bump version to 2.0 (202)
faultables Feb 3, 2026
1956dbd
feat: added web radios
f-longobardi Feb 4, 2026
0388d17
feat: playerView for radios
faultables Feb 7, 2026
90e5c01
feat: some enhacnement on playing radios
faultables Feb 7, 2026
28fabed
feat: add search and empty state on RadiosView
faultables Feb 7, 2026
7a64dd5
feat: utils to show radio source
faultables Feb 7, 2026
c1a2e99
fix: prevent some crashes on edge cases on lyrics
faultables Feb 7, 2026
d00d14c
docs(l10n): add another words
faultables Feb 7, 2026
9f2d933
feat: add placeholder album cover for radios on MP
faultables Feb 7, 2026
3260d02
feat: remove backward and forward button on radios
faultables Feb 7, 2026
1b497c3
build(release): bump version to 2.0 (203)
faultables Feb 7, 2026
907dfe7
fix: crash on smaller screen + scale factor for strings in stat card
f-longobardi Feb 6, 2026
52bc16e
fix: bottom floating view for smaller screen
f-longobardi Feb 6, 2026
d15ad2c
style: add header to UIScreen+ utility
faultables Feb 8, 2026
bcca8c7
build(release): bump version to 2.0 (204)
faultables Feb 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 60 additions & 6 deletions flo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

31 changes: 16 additions & 15 deletions flo/AlbumView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
Expand Down
2 changes: 1 addition & 1 deletion flo/ArtistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 19 additions & 10 deletions flo/ArtistsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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()
}
}
Expand All @@ -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")
}
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions flo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -106,6 +107,7 @@ struct ContentView: View {
self.isSwipping = false
}
)
.padding(.bottom, bottomPaddingForSmallerScreens)
}
}
}
Expand Down
169 changes: 90 additions & 79 deletions flo/FloatingPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,110 +8,121 @@
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<Double> = 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 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)
}
}
}
}
}
.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)
}
}

Expand Down
Loading