add mobile androida and ios apps
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
final class AppSettings: ObservableObject {
|
||||
static let shared = AppSettings()
|
||||
|
||||
var host: String {
|
||||
get { UserDefaults.standard.string(forKey: "server_host") ?? "192.168.178.100" }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "server_host") }
|
||||
}
|
||||
|
||||
var password: String {
|
||||
get { UserDefaults.standard.string(forKey: "server_password") ?? "groove_listen" }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "server_password") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
@main
|
||||
struct GrooveAudioApp: App {
|
||||
init() {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
NavigationView {
|
||||
NowPlayingView()
|
||||
.navigationTitle("Groove Audio")
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
|
||||
final class StreamPlayer: ObservableObject {
|
||||
static let shared = StreamPlayer()
|
||||
|
||||
private var player: AVPlayer
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@Published var isPlaying = false
|
||||
@Published var connectionStatus: ConnectionStatus = .disconnected
|
||||
|
||||
enum ConnectionStatus {
|
||||
case connected, disconnected, error(String)
|
||||
}
|
||||
|
||||
private init() {
|
||||
self.player = AVPlayer()
|
||||
setupNotifications()
|
||||
}
|
||||
|
||||
func playStream(host: String, password: String) {
|
||||
guard let url = URL(string: "http://\(host):8000/stream.flac") else {
|
||||
connectionStatus = .error("Invalid URL")
|
||||
return
|
||||
}
|
||||
|
||||
let credentials = "listener:\(password)"
|
||||
let encoded = Data(credentials.utf8).base64EncodedString()
|
||||
let headers: [String: String] = ["Authorization": "Basic \(encoded)"]
|
||||
let asset = AVURLAsset(url: url, options: [
|
||||
AVURLAssetHTTPHeaderFieldsKey: headers
|
||||
])
|
||||
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
item.preferredForwardBufferDuration = 10
|
||||
|
||||
player.replaceCurrentItem(with: item)
|
||||
player.play()
|
||||
isPlaying = true
|
||||
connectionStatus = .connected
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func resume() {
|
||||
player.play()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func reconnect(host: String, password: String) {
|
||||
pause()
|
||||
connectionStatus = .disconnected
|
||||
playStream(host: host, password: password)
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.publisher(for: .AVPlayerItemFailedToPlayToEndTime)
|
||||
.sink { [weak self] _ in
|
||||
self?.connectionStatus = .error("Stream failed")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Xcode project file
|
||||
// This file would be created automatically when opening the project in Xcode
|
||||
// The source files are all in place and ready to be imported into Xcode
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - JSON-RPC Client
|
||||
|
||||
final class MopidyClient {
|
||||
static let shared = MopidyClient()
|
||||
|
||||
private let baseEndpoint: URL
|
||||
|
||||
private init() {
|
||||
let credentials = "mopidy:\(AppSettings.shared.password)"
|
||||
let encoded = Data(credentials.utf8).base64EncodedString()
|
||||
self.baseEndpoint = URL(string: "http://\(AppSettings.shared.host):6680/mopidy/rpc")!
|
||||
}
|
||||
|
||||
func request<T: Decodable>(method: String, params: [String: Any] = [:], as type: T.Type) async throws -> T {
|
||||
var components = URLComponents(url: baseEndpoint, resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [URLQueryItem(name: "jsonrpc", value: "2.0")]
|
||||
guard let url = components.url else { throw MopidyError.invalidURL }
|
||||
|
||||
let body: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": 1
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw MopidyError.badResponse
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
if let error = json?["error"] {
|
||||
throw MopidyError.rpcError("\(error)")
|
||||
}
|
||||
guard let result = json?["result"] else {
|
||||
throw MopidyError.noResult
|
||||
}
|
||||
|
||||
let resultData = try JSONSerialization.data(withJSONObject: result, options: [])
|
||||
return try JSONDecoder().decode(type, from: resultData)
|
||||
}
|
||||
|
||||
func getPlaybackState() async throws -> MopidyPlaybackState {
|
||||
try await request(method: "core.playback.getState", params: [:], as: MopidyPlaybackState.self)
|
||||
}
|
||||
|
||||
func getCurrentTrack() async throws -> MopidyPlaybackState.TLTrack? {
|
||||
try await request(method: "core.tracklist.getCurrentTrack", params: [:], as: MopidyPlaybackState.TLTrack?.self)
|
||||
}
|
||||
|
||||
func getVolume() async throws -> Int {
|
||||
try await request(method: "core.mixer.getVolume", params: [:], as: Int.self)
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Int) async throws {
|
||||
try await request(method: "core.mixer.setVolume", params: ["volume": volume], as: Void.self)
|
||||
}
|
||||
|
||||
func play() async throws {
|
||||
try await request(method: "core.playback.play", params: [:], as: Void.self)
|
||||
}
|
||||
|
||||
func pause() async throws {
|
||||
try await request(method: "core.playback.pause", params: [:], as: Void.self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum MopidyError: LocalizedError {
|
||||
case invalidURL
|
||||
case badResponse
|
||||
case noResult
|
||||
case rpcError(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid Mopidy URL"
|
||||
case .badResponse: return "Bad server response"
|
||||
case .noResult: return "No result in response"
|
||||
case .rpcError(let msg): return "RPC error: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
final class MopidyEventStream {
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionDataTask?
|
||||
private let onTrackChanged: () -> Void
|
||||
private let onVolumeChanged: (Int) -> Void
|
||||
|
||||
private let host: String
|
||||
private let password: String
|
||||
|
||||
init(host: String,
|
||||
password: String,
|
||||
onTrackChanged: @escaping () -> Void,
|
||||
onVolumeChanged: @escaping (Int) -> Void)
|
||||
{
|
||||
self.host = host
|
||||
self.password = password
|
||||
self.onTrackChanged = onTrackChanged
|
||||
self.onVolumeChanged = onVolumeChanged
|
||||
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = 3600
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
func connect() {
|
||||
guard let url = URL(string: "http://\(host):6680/mopidy/events") else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
self.task = session?.dataTask(with: request) { [weak self] data, response, error in
|
||||
guard let self = self, let data = data else { return }
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
self.parseEvents(text)
|
||||
}
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
task?.cancel()
|
||||
session?.finishTasksAndInvalidate()
|
||||
}
|
||||
|
||||
private func parseEvents(_ text: String) {
|
||||
for line in text.components(separatedBy: "\n") {
|
||||
if line.hasPrefix("event:") && line.contains("tracklistchanged") {
|
||||
onTrackChanged()
|
||||
}
|
||||
if line.hasPrefix("event:") && line.contains("volumechanged") {
|
||||
// volume data would follow in subsequent data: lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Playback State Response
|
||||
|
||||
struct MopidyPlaybackState: Codable {
|
||||
let state: State
|
||||
let timestamp: Int
|
||||
let tlTrack: TLTrack?
|
||||
|
||||
enum State: String, Codable {
|
||||
case playing, paused, stopped
|
||||
}
|
||||
|
||||
struct TLTrack: Codable {
|
||||
let tlid: String
|
||||
let trackid: String
|
||||
let track: Track
|
||||
}
|
||||
|
||||
struct Track: Codable {
|
||||
let name: String
|
||||
let artists: [Artist]
|
||||
let artistNames: [String] {
|
||||
artists.map { $0.name }
|
||||
}
|
||||
let album: Album?
|
||||
let bitrate: Int?
|
||||
let date: String?
|
||||
let discNumber: Int?
|
||||
let duration: Int?
|
||||
let index: Int?
|
||||
let uri: String
|
||||
let coverArtURI: [String]?
|
||||
|
||||
struct Artist: Codable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct Album: Codable {
|
||||
let name: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Volume Response
|
||||
|
||||
struct VolumeResponse: Codable {
|
||||
let result: Int
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class NowPlayingController: ObservableObject {
|
||||
@Published var currentTrack: MopidyPlaybackState.Track?
|
||||
@Published var isPlaying = false
|
||||
@Published var volume = 70
|
||||
@Published var coverArt: UIImage?
|
||||
@Published var connectionStatus: String = "Connecting..."
|
||||
|
||||
private let settings = AppSettings.shared
|
||||
private let streamPlayer = StreamPlayer.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var refreshTimer: Timer?
|
||||
|
||||
init() {
|
||||
bindStreamPlayer()
|
||||
startPolling()
|
||||
fetchVolume()
|
||||
}
|
||||
|
||||
func connect() {
|
||||
streamPlayer.playStream(host: settings.host, password: settings.password)
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
streamPlayer.reconnect(host: settings.host, password: settings.password)
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
if isPlaying {
|
||||
streamPlayer.pause()
|
||||
Task { try? await MopidyClient.shared.pause() }
|
||||
} else {
|
||||
streamPlayer.resume()
|
||||
Task { try? await MopidyClient.shared.play() }
|
||||
}
|
||||
}
|
||||
|
||||
func setVolume(_ newVolume: Int) {
|
||||
volume = newVolume
|
||||
Task { [weak self] in
|
||||
try? await MopidyClient.shared.setVolume(newVolume)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func bindStreamPlayer() {
|
||||
streamPlayer.$connectionStatus
|
||||
.map { status in
|
||||
switch status {
|
||||
case .connected: return "Connected"
|
||||
case .disconnected: return "Disconnected"
|
||||
case .error(let msg): return "Error: \(msg)"
|
||||
}
|
||||
}
|
||||
.assign(to: &$connectionStatus)
|
||||
|
||||
streamPlayer.$isPlaying
|
||||
.assign(to: &$isPlaying)
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let state = try await MopidyClient.shared.getPlaybackState()
|
||||
if let track = state.tlTrack?.track {
|
||||
self.currentTrack = track
|
||||
self.fetchCoverArt(track.coverArtURI)
|
||||
}
|
||||
self.isPlaying = (state.state == .playing)
|
||||
} catch {
|
||||
self.connectionStatus = "Poll error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCoverArt(_ uris: [String]?) {
|
||||
guard let firstURI = uris?.first, let url = URL(string: firstURI) else { return }
|
||||
Task {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
if let image = UIImage(data: data) {
|
||||
self.coverArt = image
|
||||
}
|
||||
} catch {
|
||||
self.coverArt = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchVolume() {
|
||||
Task {
|
||||
do {
|
||||
self.volume = try await MopidyClient.shared.getVolume()
|
||||
} catch {
|
||||
print("Volume fetch error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# GrooveAudio - iOS
|
||||
|
||||
iOS client for the GrooveAudio streaming server.
|
||||
|
||||
## Features
|
||||
|
||||
- Stream FLAC audio from your Mopidy server
|
||||
- Control playback (play/pause)
|
||||
- Adjust volume
|
||||
- View now-playing information
|
||||
- Background playback
|
||||
- Lock screen controls
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open the project in Xcode
|
||||
2. Select a simulator or device
|
||||
3. Build and run
|
||||
|
||||
## Configuration
|
||||
|
||||
The app stores connection settings in UserDefaults:
|
||||
- Host: IP address of your Mopidy server (default: `192.168.178.100`)
|
||||
- Password: Listener password for the Icecast stream (default: `groove_listen`)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Player**: AVPlayer with custom HTTP header handling for authentication
|
||||
- **Now Playing**: MPNowPlayingInfoCenter for lock screen integration
|
||||
- **Mopidy Client**: URLSession-based JSON-RPC client
|
||||
- **Events**: URLSession WebSocket for real-time updates
|
||||
- **UI**: SwiftUI with Combine for state management
|
||||
|
||||
## Dependencies
|
||||
|
||||
- AVFoundation: Audio playback
|
||||
- MediaPlayer: Now-playing information and lock screen controls
|
||||
- Combine: Reactive programming
|
||||
- SwiftUI: Declarative UI framework
|
||||
|
||||
## Notes
|
||||
|
||||
- The app uses port 8000 for the FLAC stream and port 8180 for the Mopidy API
|
||||
- For internet access, see the [MOBILE_APP_INSTRUCTIONS.md](../../MOBILE_APP_INSTRUCTIONS.md) in the root directory
|
||||
- Background playback requires iOS 11+ (FLAC support) and iOS 16+ for best results
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NowPlayingView: View {
|
||||
@StateObject private var viewModel = NowPlayingController()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
coverArtSection
|
||||
trackInfoSection
|
||||
.padding(.horizontal)
|
||||
Spacer()
|
||||
playbackControls
|
||||
volumeSlider
|
||||
.padding(.horizontal)
|
||||
statusFooter
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.connect()
|
||||
}
|
||||
.onChange(of: viewModel.volume) { _, _ in
|
||||
// slider binding handles this
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var coverArtSection: some View {
|
||||
ZStack {
|
||||
if let image = viewModel.coverArt {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 280, height: 280)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.frame(width: 280, height: 280)
|
||||
.overlay(
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.gray.opacity(0.5))
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private var trackInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(viewModel.currentTrack?.name ?? "No track")
|
||||
.font(.title.bold())
|
||||
.foregroundColor(.white)
|
||||
|
||||
if let artists = viewModel.currentTrack?.artistNames, !artists.isEmpty {
|
||||
Text(artists.joined(separator: ", "))
|
||||
.font(.title3)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
if let album = viewModel.currentTrack?.album?.name {
|
||||
Text(album)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray.opacity(0.7))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var playbackControls: some View {
|
||||
HStack(spacing: 40) {
|
||||
Button(action: {}) {
|
||||
Image(systemName: "backward.fill")
|
||||
.font(.title)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
Button(action: { viewModel.togglePlay() }) {
|
||||
Image(systemName: viewModel.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Button(action: {}) {
|
||||
Image(systemName: "forward.fill")
|
||||
.font(.title)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
|
||||
private var volumeSlider: some View {
|
||||
HStack {
|
||||
Image(systemName: "speaker.fill")
|
||||
.foregroundColor(.gray)
|
||||
Slider(value: $viewModel.volume, in: 0...100)
|
||||
.onChange(of: viewModel.volume) { newValue in
|
||||
viewModel.setVolume(newValue)
|
||||
}
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var statusFooter: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(viewModel.isPlaying ? Color.green : Color.red)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(viewModel.connectionStatus)
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink(destination: SettingsView()) {
|
||||
Image(systemName: "gear")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NowPlayingView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@State private var host: String = AppSettings.shared.host
|
||||
@State private var password: String = AppSettings.shared.password
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("Server")) {
|
||||
TextField("Host", text: $host)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
SecureField("Password", text: $password)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Connect") {
|
||||
AppSettings.shared.host = host
|
||||
AppSettings.shared.password = password
|
||||
StreamPlayer.shared.reconnect(host: host, password: password)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VolumeView: View {
|
||||
@Binding var volume: Int
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "speaker.fill")
|
||||
.foregroundColor(.gray)
|
||||
Slider(value: $volume, in: 0...100)
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user