Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b045df9
feat: add artist and album navigation from album, home, and player
violetisasi Aug 14, 2026
85638af
fix: push player dests onto the app stack
violetisasi Aug 15, 2026
f967d51
feat: show circular artist images in list and detail
violetisasi Aug 15, 2026
58d9ec4
feat: show explicit tags on songs and albums
violetisasi Aug 15, 2026
6d84e9c
feat: display playlist cover art if available
eangele1 Jul 31, 2026
d098c67
feat: show playlist covers in album-style grid
violetisasi Aug 15, 2026
6126984
Fixed non-square album art pushing adjacent albums out of alignment i…
Emberr May 25, 2026
e0a307e
feat: queue scrobbles offline and submit when server is reachable
violetisasi Aug 15, 2026
c4af830
fix: make offline scrobble queue reliably enqueue and auto-submit
violetisasi Aug 16, 2026
846ccbb
fix: prevent duplicate offline scrobbles and false submission failures
violetisasi Aug 16, 2026
30c5bb5
feat: show retry countdown and failure reason in offline scrobble queue
violetisasi Aug 16, 2026
4c40ea3
fix: only queue offline scrobbles on transport failures, not decode e…
violetisasi Aug 16, 2026
bf92acc
feat: fullscreen offline scrobble queue with global retry status and …
violetisasi Aug 16, 2026
96994dc
fix: center offline scrobble action buttons and use constant retry in…
violetisasi Aug 16, 2026
e5f6995
ui: rename Done to Close in offline scrobble queue
violetisasi Aug 16, 2026
2884a2d
improved iap handling to better follow navidromes documentation (auth…
piekay Jul 22, 2026
b6c1b81
fix: revalidate IAP session on launch and surface IAP auth extraction…
violetisasi Aug 16, 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
40 changes: 40 additions & 0 deletions flo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

49 changes: 41 additions & 8 deletions flo/AlbumView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ struct AlbumView: View {

var isDownloadScreen: Bool = false

@ViewBuilder
private var albumArtistLabel: some View {
let artistName = viewModel.album.albumArtist

if let artist = viewModel.artistForNavigation(
id: viewModel.album.resolvedArtistId, name: artistName)
{
NavigationLink {
ArtistDetailView(artist: artist)
.environmentObject(viewModel)
.environmentObject(playerViewModel)
.environmentObject(downloadViewModel)
} label: {
Text(artistName)
.customFont(.title3)
.multilineTextAlignment(.center)
.foregroundColor(.accentColor)
}
.buttonStyle(.plain)
} else {
Text(artistName)
.customFont(.title3)
.multilineTextAlignment(.center)
}
}

var body: some View {
ScrollView {
VStack {
Expand Down Expand Up @@ -81,15 +107,19 @@ struct AlbumView: View {
}

VStack {
Text(viewModel.album.name)
.customFont(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)
.padding(.bottom, 5)
HStack(alignment: .center, spacing: 8) {
Text(viewModel.album.name)
.customFont(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)

Text(viewModel.album.albumArtist)
.customFont(.title3)
.multilineTextAlignment(.center)
if viewModel.album.isExplicit {
ExplicitBadge()
}
}
.padding(.bottom, 5)

albumArtistLabel
.padding(.bottom, 10)

HStack {
Expand Down Expand Up @@ -225,6 +255,9 @@ struct AlbumView: View {
}
}
}
.onAppear {
viewModel.getArtists()
}
.onReceive(downloadViewModel.$downloadWatcher) { newValue in
if newValue {
viewModel.setActiveAlbum(album: viewModel.album)
Expand Down
103 changes: 94 additions & 9 deletions flo/AlbumViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ class AlbumViewModel: ObservableObject {
// MARK: - Fetch methods

func fetchAllSongs() {
fetchCached(current: songs, cacheKey: .songs,
fetchCached(
current: songs, cacheKey: .songs,
assign: { self.songs = $0 }, request: AlbumService.shared.getAllSongs)
}

Expand Down Expand Up @@ -213,11 +214,22 @@ class AlbumViewModel: ObservableObject {
}
}

func getAlbumCoverArt(id: String, artistName: String = "", albumName: String = "", albumCover: String = "") -> String {
func getAlbumCoverArt(
id: String, artistName: String = "", albumName: String = "", albumCover: String = ""
) -> String {
return AlbumService.shared.getAlbumCover(
artistName: artistName, albumName: albumName, albumId: id, albumCover: albumCover)
}

func getPlaylistCoverArt(id: String, coverArtId: String? = nil) -> String {
let artId = coverArtId ?? id
return AlbumService.shared.getPlaylistCover(playlistId: artId)
}

func getArtistCoverArt(id: String, imageURL: String = "") -> String {
return AlbumService.shared.getArtistCover(artistId: id, imageURL: imageURL)
}

func shareAlbum(description: String, completion: @escaping (String) -> Void) {
AlbumService.shared.share(albumId: self.album.id, description: description, downloadable: false)
{ result in
Expand Down Expand Up @@ -343,7 +355,8 @@ class AlbumViewModel: ObservableObject {
}

func fetchAlbums() {
fetchCached(current: albums, cacheKey: .albums, showsLoading: true,
fetchCached(
current: albums, cacheKey: .albums, showsLoading: true,
assign: { self.albums = $0 }, request: AlbumService.shared.getAlbum)
}

Expand Down Expand Up @@ -383,34 +396,106 @@ class AlbumViewModel: ObservableObject {
}

func getPlaylists() {
fetchCached(current: playlists, cacheKey: .playlists,
fetchCached(
current: playlists, cacheKey: .playlists,
assign: { self.playlists = $0 }, request: AlbumService.shared.getPlaylists)
}

func getArtists() {
fetchCached(current: artists, cacheKey: .artists,
fetchCached(
current: artists, cacheKey: .artists,
assign: { self.artists = $0 }, request: AlbumService.shared.getArtists)
}

func artistForNavigation(id: String = "", name: String) -> Artist? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let hasName = !trimmedName.isEmpty && trimmedName != "N/A"

if !id.isEmpty, let match = artists.first(where: { $0.id == id }) {
return match
}

if hasName,
let match = artists.first(where: {
$0.name.caseInsensitiveCompare(trimmedName) == .orderedSame
})
{
return match
}

if !id.isEmpty {
return Artist.placeholder(id: id, name: hasName ? trimmedName : name)
}

return nil
}

func albumForNavigation(id: String = "", name: String, artist: String = "") -> Album? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let hasName = !trimmedName.isEmpty && trimmedName != "N/A"

if !id.isEmpty {
if let match = albums.first(where: { $0.id == id }) {
return match
}

if let match = downloadedAlbums.first(where: { $0.id == id }) {
return match
}

return Album(
id: id,
name: hasName ? trimmedName : name,
albumArtist: artist,
artist: artist
)
}

guard hasName else { return nil }

let matchingAlbums = albums.filter {
$0.name.caseInsensitiveCompare(trimmedName) == .orderedSame
}

if matchingAlbums.isEmpty {
return downloadedAlbums.first(where: {
$0.name.caseInsensitiveCompare(trimmedName) == .orderedSame
})
}

if artist.isEmpty || artist == "N/A" {
return matchingAlbums.first
}

return matchingAlbums.first(where: {
$0.albumArtist.caseInsensitiveCompare(artist) == .orderedSame
|| $0.artist.caseInsensitiveCompare(artist) == .orderedSame
}) ?? matchingAlbums.first
}

// MARK: - Async refresh variants

@MainActor func refreshAlbums() async {
await refreshCached(cacheKey: .albums, assign: { self.albums = $0 },
await refreshCached(
cacheKey: .albums, assign: { self.albums = $0 },
request: AlbumService.shared.getAlbum)
}

@MainActor func refreshArtists() async {
await refreshCached(cacheKey: .artists, assign: { self.artists = $0 },
await refreshCached(
cacheKey: .artists, assign: { self.artists = $0 },
request: AlbumService.shared.getArtists)
}

@MainActor func refreshPlaylists() async {
await refreshCached(cacheKey: .playlists, assign: { self.playlists = $0 },
await refreshCached(
cacheKey: .playlists, assign: { self.playlists = $0 },
request: AlbumService.shared.getPlaylists)
}

@MainActor func refreshAllSongs() async {
await refreshCached(cacheKey: .songs, assign: { self.songs = $0 },
await refreshCached(
cacheKey: .songs, assign: { self.songs = $0 },
request: AlbumService.shared.getAllSongs)
}

Expand Down
85 changes: 39 additions & 46 deletions flo/AlbumsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,71 +20,51 @@ struct AlbumsView: View {
if self.isDownloadScreen {
if let image = UIImage(
contentsOfFile: viewModel.getAlbumCoverArt(
id: album.id, artistName: album.artist, albumName: album.name, albumCover: album.albumCover))
id: album.id, artistName: album.artist, albumName: album.name,
albumCover: album.albumCover))
{
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: 300)
.clipShape(
RoundedRectangle(cornerRadius: 5, style: .continuous)
)
albumArtwork(Image(uiImage: image))
} else {
if let image = UIImage(named: "placeholder") {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: 300)
.clipShape(
RoundedRectangle(cornerRadius: 5, style: .continuous)
)
albumArtwork(Image(uiImage: image))
}
}
} else {
if let image = UIImage(
contentsOfFile: viewModel.getAlbumCoverArt(id: album.id, albumCover: album.albumCover))
{
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: 300)
.clipShape(
RoundedRectangle(cornerRadius: 5, style: .continuous)
)
albumArtwork(Image(uiImage: image))
} else {
LazyImage(url: URL(string: viewModel.getAlbumCoverArt(id: album.id, albumCover: album.albumCover))) { state in
LazyImage(
url: URL(
string: viewModel.getAlbumCoverArt(id: album.id, albumCover: album.albumCover))
) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: 300)
.clipShape(
RoundedRectangle(cornerRadius: 5, style: .continuous)
)
albumArtwork(image)
} else {
if let image = UIImage(named: "placeholder") {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: 300)
.clipShape(
RoundedRectangle(cornerRadius: 5, style: .continuous)
)
albumArtwork(Image(uiImage: image))
}
}
}
}
}

Text(album.name)
.customFont(.caption1)
.fontWeight(.bold)
.foregroundColor(.primary)
.truncationMode(.tail)
.padding(.trailing, 20)
.lineLimit(1)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(alignment: .center, spacing: 4) {
Text(album.name)
.customFont(.caption1)
.fontWeight(.bold)
.foregroundColor(.primary)
.truncationMode(.tail)
.lineLimit(1)
.multilineTextAlignment(.leading)

if album.isExplicit {
ExplicitBadge(size: .compact)
}
}
.padding(.trailing, 20)
.frame(maxWidth: .infinity, alignment: .leading)

Text(album.albumArtist)
.customFont(.caption2)
Expand All @@ -96,6 +76,19 @@ struct AlbumsView: View {
}.padding()
}
}

private func albumArtwork(_ image: Image) -> some View {
GeometryReader { proxy in
image
.resizable()
.scaledToFill()
.frame(width: proxy.size.width, height: proxy.size.width)
.clipShape(
RoundedRectangle(cornerRadius: 5, style: .continuous)
)
}
.aspectRatio(1, contentMode: .fit)
}
}

struct AlbumsView_Preview: PreviewProvider {
Expand Down
33 changes: 19 additions & 14 deletions flo/Artists/ArtistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,26 @@ struct ArtistDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text(artist.name)
.customFont(.title)
.fontWeight(.bold)
.multilineTextAlignment(.leading)
.padding(.bottom, 3)
.frame(maxWidth: .infinity, alignment: .leading)

Text(stripBiography(biography: artist.biography ?? ""))
.customFont(.subheadline)
.lineSpacing(3)
.multilineTextAlignment(.leading)
.lineLimit(isExpanded ? nil : 3)
.onTapGesture {
isExpanded.toggle()
HStack(alignment: .top, spacing: 16) {
ArtistImageView(artist: artist, size: 88)

VStack(alignment: .leading, spacing: 6) {
Text(artist.name)
.customFont(.title)
.fontWeight(.bold)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)

Text(stripBiography(biography: artist.biography ?? ""))
.customFont(.subheadline)
.lineSpacing(3)
.multilineTextAlignment(.leading)
.lineLimit(isExpanded ? nil : 3)
.onTapGesture {
isExpanded.toggle()
}
}
}
}
.padding()
.onAppear {
Expand Down
Loading
Loading