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" } }