add mobile androida and ios apps

This commit is contained in:
2026-05-18 22:30:45 +02:00
parent 0e40a23828
commit 6146bf18b0
32 changed files with 1594 additions and 0 deletions
+15
View File
@@ -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)")
}
}
}
}
+45
View File
@@ -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
+131
View File
@@ -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()
}
}
+29
View File
@@ -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)
}
}
+16
View File
@@ -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)
}
}