Merge branch 'main' of ssh://dallasgroot@10.0.0.224:22/Users/dallasgroot/NavidromePlayer/.git
This commit is contained in:
commit
f1d3d41340
8 changed files with 671 additions and 287 deletions
|
|
@ -105,64 +105,44 @@ class WatchConnectivityManager: NSObject, ObservableObject {
|
|||
return false
|
||||
}
|
||||
|
||||
// Check if local file is already a small MP3 — use it directly
|
||||
if let localURL = OfflineManager.shared.localURL(for: song.id),
|
||||
FileManager.default.fileExists(atPath: localURL.path) {
|
||||
let suffix = (song.suffix ?? "").lowercased()
|
||||
let fileSize = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0
|
||||
let isSmallMP3 = suffix == "mp3" && fileSize < 15_000_000 // < 15MB
|
||||
|
||||
if isSmallMP3 {
|
||||
wcLog("Local MP3 is small enough (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file))), sending directly")
|
||||
return transferFile(localURL, song: song, suffix: "mp3")
|
||||
}
|
||||
}
|
||||
// Send a message telling the watch to download directly from server.
|
||||
// The watch has its own server credentials and can stream at 192kbps MP3
|
||||
// without needing the iPhone to download and transfer.
|
||||
let metadata: [String: Any] = [
|
||||
"type": "downloadSong",
|
||||
"songId": song.id,
|
||||
"title": song.title,
|
||||
"artist": song.artist ?? "Unknown",
|
||||
"album": song.album ?? "Unknown",
|
||||
"duration": song.duration ?? 0,
|
||||
"coverArt": song.coverArt ?? "",
|
||||
"suffix": song.suffix ?? "mp3"
|
||||
]
|
||||
|
||||
// Otherwise, download a transcoded MP3 192kbps from server
|
||||
wcLog("Transcoding to MP3 192kbps via server stream endpoint")
|
||||
if session.isReachable {
|
||||
// Watch is active — send interactive message (faster)
|
||||
session.sendMessage(metadata, replyHandler: { reply in
|
||||
self.wcLog("Watch acknowledged download: \(reply)")
|
||||
}, errorHandler: { error in
|
||||
self.wcLog("Watch message failed: \(error.localizedDescription) — falling back to userInfo")
|
||||
// Fall back to transferUserInfo (queued, delivered when watch wakes)
|
||||
session.transferUserInfo(metadata)
|
||||
})
|
||||
} else {
|
||||
// Watch not reachable — queue for delivery when it wakes
|
||||
session.transferUserInfo(metadata)
|
||||
wcLog("Watch not reachable — queued download command via transferUserInfo")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.transferringIds.insert(song.id)
|
||||
self.transferProgressMap[song.id] = 0.0
|
||||
self.transferTitleMap[song.id] = song.title
|
||||
}
|
||||
|
||||
guard let streamURL = ServerManager.shared.client.streamURL(
|
||||
songId: song.id,
|
||||
format: "mp3",
|
||||
maxBitRate: 192
|
||||
) else {
|
||||
wcLog("FAIL: Could not build stream URL")
|
||||
DispatchQueue.main.async {
|
||||
|
||||
// Auto-remove from transferring after 30s since we can't track watch download progress
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
|
||||
self.transferringIds.remove(song.id)
|
||||
self.transferProgressMap.removeValue(forKey: song.id)
|
||||
self.transferTitleMap.removeValue(forKey: song.id)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Download transcoded file to temp, then transfer
|
||||
Task {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: streamURL)
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let tempFile = tempDir.appendingPathComponent("watch_\(song.id).mp3")
|
||||
try data.write(to: tempFile)
|
||||
|
||||
let size = ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
|
||||
self.wcLog("Transcoded: \(size) → sending to watch")
|
||||
|
||||
await MainActor.run {
|
||||
_ = self.transferFile(tempFile, song: song, suffix: "mp3")
|
||||
}
|
||||
} catch {
|
||||
self.wcLog("FAIL: Transcode download failed: \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
self.transferringIds.remove(song.id)
|
||||
self.transferProgressMap.removeValue(forKey: song.id)
|
||||
self.transferTitleMap.removeValue(forKey: song.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 285 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
|
|
|||
93
update.sh
Executable file
93
update.sh
Executable file
|
|
@ -0,0 +1,93 @@
|
|||
#!/bin/bash
|
||||
|
||||
# update.sh - Unzips a new NavidromePlayer.zip and replaces all project files
|
||||
# while preserving your .git history.
|
||||
#
|
||||
# Usage: Place this script in your NavidromePlayer/ root directory.
|
||||
# Drop the new NavidromePlayer.zip in the SAME directory.
|
||||
# Then run: ./update.sh
|
||||
#
|
||||
# It will:
|
||||
# 1. Auto-commit any uncommitted changes
|
||||
# 2. Delete all project files (keeps .git and this script)
|
||||
# 3. Unzip the new version
|
||||
# 4. Show you what changed
|
||||
# 5. Commit with the zip's git message or a default
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
ZIP_FILE="NavidromePlayer.zip"
|
||||
|
||||
# Check zip exists
|
||||
if [ ! -f "$ZIP_FILE" ]; then
|
||||
echo "❌ $ZIP_FILE not found in $(pwd)"
|
||||
echo " Drop the zip file here and run again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check we're in a git repo
|
||||
if [ ! -d ".git" ]; then
|
||||
echo "❌ No .git directory found. Run this from your NavidromePlayer repo root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Updating NavidromePlayer from $ZIP_FILE"
|
||||
echo ""
|
||||
|
||||
# Step 1: Auto-commit any uncommitted work
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "💾 Committing your uncommitted changes first..."
|
||||
git add -A
|
||||
git commit -m "Auto-commit before update ($(date '+%Y-%m-%d %H:%M'))"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Step 2: Delete everything except .git, this script, and the zip
|
||||
echo "🗑 Removing old project files..."
|
||||
find . -maxdepth 1 \
|
||||
-not -name '.' \
|
||||
-not -name '.git' \
|
||||
-not -name "$ZIP_FILE" \
|
||||
-not -name 'update.sh' \
|
||||
-exec rm -rf {} \;
|
||||
|
||||
# Step 3: Unzip
|
||||
echo "📂 Extracting $ZIP_FILE..."
|
||||
unzip -q -o "$ZIP_FILE"
|
||||
|
||||
# Move files out of the NavidromePlayer/ wrapper folder
|
||||
if [ -d "NavidromePlayer" ]; then
|
||||
# Use rsync to handle dotfiles properly
|
||||
shopt -s dotglob
|
||||
mv NavidromePlayer/* . 2>/dev/null || true
|
||||
shopt -u dotglob
|
||||
rmdir NavidromePlayer 2>/dev/null || rm -rf NavidromePlayer
|
||||
fi
|
||||
|
||||
# Step 4: Show diff
|
||||
echo ""
|
||||
echo "📊 Changes:"
|
||||
git add -A
|
||||
git diff --cached --stat
|
||||
echo ""
|
||||
|
||||
# Step 5: Count changes
|
||||
CHANGED=$(git diff --cached --numstat | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$CHANGED" -eq "0" ]; then
|
||||
echo "✅ No changes - you're already up to date."
|
||||
git reset HEAD . > /dev/null 2>&1
|
||||
else
|
||||
# Commit
|
||||
echo "💾 Committing $CHANGED changed files..."
|
||||
git commit -m "Update from NavidromePlayer.zip ($(date '+%Y-%m-%d %H:%M'))"
|
||||
echo ""
|
||||
echo "✅ Done! Latest commits:"
|
||||
git log --oneline -5
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔨 Don't forget to run: ./generate.sh"
|
||||
|
|
@ -97,15 +97,49 @@ class WatchSessionManager: NSObject, ObservableObject {
|
|||
|
||||
// MARK: - Direct API Access (watch can also talk to server directly)
|
||||
|
||||
func getAlbumList(type: String = "newest", size: Int = 20) async throws -> [Album] {
|
||||
func getArtists() async throws -> [ArtistIndex] {
|
||||
guard activeServer != nil else { return [] }
|
||||
return try await client.getAlbumList2(type: type, size: size)
|
||||
return try await client.getArtists()
|
||||
}
|
||||
|
||||
func getArtist(id: String) async throws -> ArtistWithAlbums? {
|
||||
return try await client.getArtist(id: id)
|
||||
}
|
||||
|
||||
func getGenres() async throws -> [Genre] {
|
||||
guard activeServer != nil else { return [] }
|
||||
return try await client.getGenres()
|
||||
}
|
||||
|
||||
func getAlbumList(type: String = "newest", size: Int = 20, offset: Int = 0) async throws -> [Album] {
|
||||
guard activeServer != nil else { return [] }
|
||||
return try await client.getAlbumList2(type: type, size: size, offset: offset)
|
||||
}
|
||||
|
||||
/// Fetch ALL albums (paginated until exhausted)
|
||||
func getAllAlbums() async throws -> [Album] {
|
||||
guard activeServer != nil else { return [] }
|
||||
var all: [Album] = []
|
||||
var offset = 0
|
||||
let pageSize = 500
|
||||
while true {
|
||||
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
|
||||
all.append(contentsOf: page)
|
||||
if page.count < pageSize { break }
|
||||
offset += pageSize
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func getAlbum(id: String) async throws -> AlbumWithSongs? {
|
||||
return try await client.getAlbum(id: id)
|
||||
}
|
||||
|
||||
func getAlbumsByGenre(_ genre: String) async throws -> [Album] {
|
||||
guard activeServer != nil else { return [] }
|
||||
return try await client.getAlbumList2(type: "byGenre", size: 500, genre: genre)
|
||||
}
|
||||
|
||||
func getPlaylists() async throws -> [Playlist] {
|
||||
return try await client.getPlaylists()
|
||||
}
|
||||
|
|
@ -136,6 +170,117 @@ class WatchSessionManager: NSObject, ObservableObject {
|
|||
return try await client.requestData(endpoint: "stream", params: params)
|
||||
}
|
||||
|
||||
// MARK: - Watch Library Cache (UserDefaults, persists across launches)
|
||||
|
||||
private let cachePrefix = "wcache_"
|
||||
|
||||
func cacheEncode<T: Encodable>(_ data: T, key: String) {
|
||||
if let encoded = try? JSONEncoder().encode(data) {
|
||||
UserDefaults.standard.set(encoded, forKey: cachePrefix + key)
|
||||
}
|
||||
}
|
||||
|
||||
func cacheDecode<T: Decodable>(_ type: T.Type, key: String) -> T? {
|
||||
guard let data = UserDefaults.standard.data(forKey: cachePrefix + key) else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
/// Fetch with cache-first pattern: returns cached immediately, refreshes in background
|
||||
func cachedArtists() -> [ArtistIndex] {
|
||||
return cacheDecode([ArtistIndex].self, key: "artists") ?? []
|
||||
}
|
||||
|
||||
func cachedGenres() -> [Genre] {
|
||||
return cacheDecode([Genre].self, key: "genres") ?? []
|
||||
}
|
||||
|
||||
func cachedAlbums() -> [Album] {
|
||||
return cacheDecode([Album].self, key: "all_albums") ?? []
|
||||
}
|
||||
|
||||
func cachedPlaylists() -> [Playlist] {
|
||||
return cacheDecode([Playlist].self, key: "playlists") ?? []
|
||||
}
|
||||
|
||||
func cachedAlbumDetail(id: String) -> AlbumWithSongs? {
|
||||
return cacheDecode(AlbumWithSongs.self, key: "album_\(id)")
|
||||
}
|
||||
|
||||
func cachedArtistDetail(id: String) -> ArtistWithAlbums? {
|
||||
return cacheDecode(ArtistWithAlbums.self, key: "artist_\(id)")
|
||||
}
|
||||
|
||||
/// Full sync — called on launch, caches everything
|
||||
func syncLibrary() async {
|
||||
guard activeServer != nil else { return }
|
||||
await MainActor.run { isSyncing = true }
|
||||
|
||||
do {
|
||||
let artists = try await getArtists()
|
||||
cacheEncode(artists, key: "artists")
|
||||
|
||||
let genres = try await getGenres()
|
||||
cacheEncode(genres, key: "genres")
|
||||
|
||||
let albums = try await getAllAlbums()
|
||||
cacheEncode(albums, key: "all_albums")
|
||||
|
||||
let playlists = try await getPlaylists()
|
||||
cacheEncode(playlists, key: "playlists")
|
||||
|
||||
let recent = try await getAlbumList(type: "newest", size: 30)
|
||||
cacheEncode(recent, key: "recent")
|
||||
|
||||
print("[Watch] Library synced: \(artists.flatMap { $0.artist ?? [] }.count) artists, \(albums.count) albums, \(genres.count) genres")
|
||||
} catch {
|
||||
print("[Watch] Library sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
await MainActor.run { isSyncing = false }
|
||||
}
|
||||
|
||||
// MARK: - Server-Direct Download Handler
|
||||
|
||||
/// Called when iPhone tells us to download a song directly from server.
|
||||
/// Watch downloads at 192kbps MP3 — no iPhone storage or transfer needed.
|
||||
private func handleDownloadCommand(_ message: [String: Any]) {
|
||||
guard let songId = message["songId"] as? String else {
|
||||
print("[Watch] downloadSong: missing songId")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if already downloaded
|
||||
if WatchOfflineStore.shared.isSongAvailable(songId) {
|
||||
print("[Watch] Already downloaded: \(songId)")
|
||||
return
|
||||
}
|
||||
|
||||
let song = Song(
|
||||
id: songId,
|
||||
parent: nil, isDir: false,
|
||||
title: message["title"] as? String ?? "Unknown",
|
||||
album: message["album"] as? String,
|
||||
artist: message["artist"] as? String,
|
||||
track: nil, year: nil, genre: nil,
|
||||
coverArt: message["coverArt"] as? String,
|
||||
size: nil, contentType: nil,
|
||||
suffix: message["suffix"] as? String ?? "mp3",
|
||||
transcodedContentType: nil, transcodedSuffix: nil,
|
||||
duration: message["duration"] as? Int,
|
||||
bitRate: nil, path: nil, playCount: nil,
|
||||
discNumber: nil, created: nil,
|
||||
albumId: nil, artistId: nil,
|
||||
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
|
||||
)
|
||||
|
||||
print("[Watch] Downloading from server: \(song.title)")
|
||||
|
||||
Task {
|
||||
await WatchOfflineStore.shared.downloadFromServer(song: song)
|
||||
print("[Watch] Download complete: \(song.title)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func saveLocalServers() {
|
||||
|
|
@ -224,7 +369,7 @@ extension WatchSessionManager: WCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// Receive messages (now playing info from iPhone)
|
||||
// Receive messages (now playing info + download commands from iPhone)
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let type = message["type"] as? String else { return }
|
||||
|
||||
|
|
@ -240,6 +385,8 @@ extension WatchSessionManager: WCSessionDelegate {
|
|||
coverArtId: message["coverArtId"] as? String
|
||||
)
|
||||
}
|
||||
} else if type == "downloadSong" {
|
||||
handleDownloadCommand(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,6 +398,10 @@ extension WatchSessionManager: WCSessionDelegate {
|
|||
}
|
||||
|
||||
switch type {
|
||||
case "downloadSong":
|
||||
handleDownloadCommand(message)
|
||||
replyHandler(["status": "downloading"])
|
||||
|
||||
case "requestSongList":
|
||||
let store = WatchOfflineStore.shared
|
||||
struct WatchSongInfoEnc: Codable {
|
||||
|
|
@ -289,13 +440,14 @@ extension WatchSessionManager: WCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// Receive user info (wake signals, queued data from phone)
|
||||
// Receive user info (wake signals, queued download commands from phone)
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
if let type = userInfo["type"] as? String {
|
||||
print("[Watch] Received userInfo: \(type)")
|
||||
if type == "wake" {
|
||||
// Phone pinged us — we're alive now and ready to receive files
|
||||
print("[Watch] Wake signal received — app is active")
|
||||
} else if type == "downloadSong" {
|
||||
handleDownloadCommand(userInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 285 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
// MARK: - Main Tab View
|
||||
|
||||
struct WatchLibraryView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@EnvironmentObject var audioPlayer: WatchAudioPlayer
|
||||
|
|
@ -7,23 +9,283 @@ struct WatchLibraryView: View {
|
|||
|
||||
var body: some View {
|
||||
TabView {
|
||||
// Now Playing
|
||||
WatchNowPlayingView()
|
||||
|
||||
// Offline Library
|
||||
WatchMyMusicView()
|
||||
WatchOfflineLibraryView()
|
||||
|
||||
// Browse Server
|
||||
WatchBrowseView()
|
||||
|
||||
// Settings
|
||||
WatchSettingsView()
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
.task {
|
||||
// Sync library in background on launch
|
||||
await watchManager.syncLibrary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - My Music (Artists, Albums, Genres, Songs)
|
||||
|
||||
struct WatchMyMusicView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
NavigationLink(destination: WatchArtistsView()) {
|
||||
Label("Artists", systemImage: "music.mic")
|
||||
}
|
||||
NavigationLink(destination: WatchAlbumsView()) {
|
||||
Label("Albums", systemImage: "square.stack")
|
||||
}
|
||||
NavigationLink(destination: WatchGenresView()) {
|
||||
Label("Genres", systemImage: "guitars")
|
||||
}
|
||||
NavigationLink(destination: WatchPlaylistsListView()) {
|
||||
Label("Playlists", systemImage: "list.bullet")
|
||||
}
|
||||
NavigationLink(destination: WatchSearchView()) {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
}
|
||||
.navigationTitle("My Music")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Artists
|
||||
|
||||
struct WatchArtistsView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@State private var artistIndexes: [ArtistIndex] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if artistIndexes.isEmpty && isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List {
|
||||
ForEach(artistIndexes) { index in
|
||||
if let artists = index.artist, !artists.isEmpty {
|
||||
Section(index.name) {
|
||||
ForEach(artists) { artist in
|
||||
NavigationLink(destination: WatchArtistDetailView(artistId: artist.id, artistName: artist.name)) {
|
||||
Text(artist.name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.task {
|
||||
// Cache first
|
||||
let cached = watchManager.cachedArtists()
|
||||
if !cached.isEmpty {
|
||||
artistIndexes = cached
|
||||
isLoading = false
|
||||
}
|
||||
// Refresh
|
||||
do {
|
||||
let fresh = try await watchManager.getArtists()
|
||||
watchManager.cacheEncode(fresh, key: "artists")
|
||||
await MainActor.run {
|
||||
artistIndexes = fresh
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchArtistDetailView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@EnvironmentObject var audioPlayer: WatchAudioPlayer
|
||||
@EnvironmentObject var offlineStore: WatchOfflineStore
|
||||
|
||||
let artistId: String
|
||||
let artistName: String
|
||||
|
||||
@State private var artist: ArtistWithAlbums?
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let artist = artist {
|
||||
List {
|
||||
if let albums = artist.album, !albums.isEmpty {
|
||||
ForEach(albums) { album in
|
||||
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(album.name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
if let year = album.year {
|
||||
Text("\(year)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
Text("\(album.songCount ?? 0) songs")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Not available")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.navigationTitle(artistName)
|
||||
.task {
|
||||
if let cached = watchManager.cachedArtistDetail(id: artistId) {
|
||||
artist = cached
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
let fresh = try await watchManager.getArtist(id: artistId)
|
||||
if let fresh {
|
||||
watchManager.cacheEncode(fresh, key: "artist_\(artistId)")
|
||||
await MainActor.run { artist = fresh; isLoading = false }
|
||||
}
|
||||
} catch { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Albums (All)
|
||||
|
||||
struct WatchAlbumsView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@State private var albums: [Album] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if albums.isEmpty && isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List(albums) { album in
|
||||
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(album.name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Text(album.artist ?? "")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Albums")
|
||||
.task {
|
||||
let cached = watchManager.cachedAlbums()
|
||||
if !cached.isEmpty {
|
||||
albums = cached
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
let fresh = try await watchManager.getAllAlbums()
|
||||
watchManager.cacheEncode(fresh, key: "all_albums")
|
||||
await MainActor.run { albums = fresh; isLoading = false }
|
||||
} catch { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Genres
|
||||
|
||||
struct WatchGenresView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@State private var genres: [Genre] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if genres.isEmpty && isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List(genres) { genre in
|
||||
NavigationLink(destination: WatchGenreDetailView(genreName: genre.value)) {
|
||||
HStack {
|
||||
Text(genre.value)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(genre.albumCount ?? 0)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Genres")
|
||||
.task {
|
||||
let cached = watchManager.cachedGenres()
|
||||
if !cached.isEmpty {
|
||||
genres = cached
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
let fresh = try await watchManager.getGenres()
|
||||
watchManager.cacheEncode(fresh, key: "genres")
|
||||
await MainActor.run { genres = fresh; isLoading = false }
|
||||
} catch { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchGenreDetailView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
let genreName: String
|
||||
|
||||
@State private var albums: [Album] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if albums.isEmpty && isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List(albums) { album in
|
||||
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(album.name).font(.caption).lineLimit(1)
|
||||
Text(album.artist ?? "").font(.caption2).foregroundColor(.gray).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(genreName)
|
||||
.task {
|
||||
do {
|
||||
albums = try await watchManager.getAlbumsByGenre(genreName)
|
||||
isLoading = false
|
||||
} catch { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Offline Library
|
||||
|
||||
struct WatchOfflineLibraryView: View {
|
||||
@EnvironmentObject var offlineStore: WatchOfflineStore
|
||||
@EnvironmentObject var audioPlayer: WatchAudioPlayer
|
||||
|
|
@ -39,39 +301,29 @@ struct WatchOfflineLibraryView: View {
|
|||
Text("No Offline Songs")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
Text("Send songs from your iPhone or download from Browse")
|
||||
Text("Download from Browse or send from iPhone")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
// Play controls
|
||||
Section {
|
||||
Button(action: playAllOffline) {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
Text("Play All")
|
||||
}
|
||||
.foregroundColor(.pink)
|
||||
HStack { Image(systemName: "play.fill"); Text("Play All") }.foregroundColor(.pink)
|
||||
}
|
||||
|
||||
Button(action: shuffleOffline) {
|
||||
HStack {
|
||||
Image(systemName: "shuffle")
|
||||
Text("Shuffle All")
|
||||
}
|
||||
.foregroundColor(.pink)
|
||||
HStack { Image(systemName: "shuffle"); Text("Shuffle All") }.foregroundColor(.pink)
|
||||
}
|
||||
}
|
||||
|
||||
// Songs by album (swipe to delete)
|
||||
ForEach(Array(offlineStore.songsByAlbum.keys.sorted()), id: \.self) { album in
|
||||
Section(album) {
|
||||
ForEach(offlineStore.songsByAlbum[album] ?? []) { offline in
|
||||
WatchSongRow(
|
||||
song: offline.song,
|
||||
isPlaying: audioPlayer.currentSong?.id == offline.id
|
||||
isPlaying: audioPlayer.currentSong?.id == offline.id,
|
||||
isOffline: true
|
||||
) {
|
||||
let albumSongs = (offlineStore.songsByAlbum[album] ?? []).map { $0.song }
|
||||
let idx = albumSongs.firstIndex(where: { $0.id == offline.id }) ?? 0
|
||||
|
|
@ -80,42 +332,26 @@ struct WatchOfflineLibraryView: View {
|
|||
}
|
||||
.onDelete { indexSet in
|
||||
let albumSongs = offlineStore.songsByAlbum[album] ?? []
|
||||
for idx in indexSet {
|
||||
if idx < albumSongs.count {
|
||||
offlineStore.removeSong(albumSongs[idx].id)
|
||||
}
|
||||
}
|
||||
for idx in indexSet { if idx < albumSongs.count { offlineStore.removeSong(albumSongs[idx].id) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Storage info + delete all
|
||||
Section {
|
||||
HStack {
|
||||
Text("\(offlineStore.songs.count) songs")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
Text("\(offlineStore.songs.count) songs").font(.caption).foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text(offlineStore.formattedSize)
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
Text(offlineStore.formattedSize).font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
|
||||
Button(role: .destructive, action: { showDeleteAll = true }) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("Remove All")
|
||||
}
|
||||
.font(.caption)
|
||||
HStack { Image(systemName: "trash"); Text("Remove All") }.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.navigationTitle("Offline")
|
||||
.alert("Remove All Songs?", isPresented: $showDeleteAll) {
|
||||
Button("Remove", role: .destructive) { offlineStore.removeAll() }
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("This will delete \(offlineStore.songs.count) songs (\(offlineStore.formattedSize)) from your watch.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,6 +372,7 @@ struct WatchOfflineLibraryView: View {
|
|||
}
|
||||
|
||||
// MARK: - Browse Server
|
||||
|
||||
struct WatchBrowseView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
|
||||
|
|
@ -145,25 +382,18 @@ struct WatchBrowseView: View {
|
|||
NavigationLink(destination: WatchRecentAlbumsView()) {
|
||||
Label("Recent Albums", systemImage: "clock")
|
||||
}
|
||||
|
||||
NavigationLink(destination: WatchPlaylistsListView()) {
|
||||
Label("Playlists", systemImage: "list.bullet")
|
||||
}
|
||||
|
||||
NavigationLink(destination: WatchSearchView()) {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
|
||||
// Server info
|
||||
if let server = watchManager.activeServer {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.caption)
|
||||
Text(server.url)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
Text(server.name).font(.caption)
|
||||
Text(server.url).font(.caption2).foregroundColor(.gray).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +403,8 @@ struct WatchBrowseView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Recent Albums on Watch
|
||||
// MARK: - Recent Albums
|
||||
|
||||
struct WatchRecentAlbumsView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@State private var albums: [Album] = []
|
||||
|
|
@ -181,19 +412,14 @@ struct WatchRecentAlbumsView: View {
|
|||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
if albums.isEmpty && isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List(albums) { album in
|
||||
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(album.name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Text(album.artist ?? "")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
Text(album.name).font(.caption).lineLimit(1)
|
||||
Text(album.artist ?? "").font(.caption2).foregroundColor(.gray).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -201,103 +427,86 @@ struct WatchRecentAlbumsView: View {
|
|||
}
|
||||
.navigationTitle("Recent")
|
||||
.task {
|
||||
do {
|
||||
albums = try await watchManager.getAlbumList(type: "newest", size: 30)
|
||||
isLoading = false
|
||||
} catch {
|
||||
if let cached: [Album] = watchManager.cacheDecode([Album].self, key: "recent") {
|
||||
albums = cached
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
let fresh = try await watchManager.getAlbumList(type: "newest", size: 30)
|
||||
watchManager.cacheEncode(fresh, key: "recent")
|
||||
await MainActor.run { albums = fresh; isLoading = false }
|
||||
} catch { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Album Detail on Watch
|
||||
// MARK: - Album Detail
|
||||
|
||||
struct WatchAlbumDetailView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@EnvironmentObject var audioPlayer: WatchAudioPlayer
|
||||
@EnvironmentObject var offlineStore: WatchOfflineStore
|
||||
|
||||
let albumId: String
|
||||
|
||||
@State private var album: AlbumWithSongs?
|
||||
@State private var isLoading = true
|
||||
@State private var isDownloading = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else if let album = album {
|
||||
if let album = album {
|
||||
List {
|
||||
// Album header
|
||||
VStack(spacing: 4) {
|
||||
Text(album.name)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(album.artist ?? "")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.pink)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
// Play / Download buttons
|
||||
Button(action: { playAlbum(shuffle: false) }) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
.foregroundColor(.pink)
|
||||
}
|
||||
|
||||
Button(action: { playAlbum(shuffle: true) }) {
|
||||
Label("Shuffle", systemImage: "shuffle")
|
||||
.foregroundColor(.pink)
|
||||
}
|
||||
|
||||
Button(action: downloadAll) {
|
||||
HStack {
|
||||
if isDownloading {
|
||||
ProgressView().scaleEffect(0.6)
|
||||
// Download button
|
||||
Section {
|
||||
Button(action: downloadAlbum) {
|
||||
HStack {
|
||||
Image(systemName: isDownloading ? "arrow.down.circle.fill" : "arrow.down.circle")
|
||||
.foregroundColor(isDownloading ? .gray : .pink)
|
||||
Text(isDownloading ? "Downloading..." : "Download Album")
|
||||
.font(.caption)
|
||||
}
|
||||
Label(
|
||||
isDownloading ? "Downloading..." : "Download for Offline",
|
||||
systemImage: "arrow.down.circle"
|
||||
)
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.disabled(isDownloading)
|
||||
}
|
||||
.disabled(isDownloading)
|
||||
|
||||
// Songs
|
||||
ForEach(album.song ?? []) { song in
|
||||
WatchSongRow(
|
||||
song: song,
|
||||
isPlaying: audioPlayer.currentSong?.id == song.id,
|
||||
isOffline: offlineStore.isSongAvailable(song.id)
|
||||
) {
|
||||
let songs = album.song ?? []
|
||||
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
|
||||
audioPlayer.play(song: song, fromQueue: songs, at: idx)
|
||||
if let songs = album.song {
|
||||
ForEach(songs) { song in
|
||||
WatchSongRow(
|
||||
song: song,
|
||||
isPlaying: audioPlayer.currentSong?.id == song.id,
|
||||
isOffline: offlineStore.isSongAvailable(song.id)
|
||||
) {
|
||||
audioPlayer.play(song: song, fromQueue: songs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(album.name)
|
||||
} else if isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Not available").font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
album = try await watchManager.getAlbum(id: albumId)
|
||||
isLoading = false
|
||||
} catch {
|
||||
// Cache first
|
||||
if let cached = watchManager.cachedAlbumDetail(id: albumId) {
|
||||
album = cached
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
let fresh = try await watchManager.getAlbum(id: albumId)
|
||||
if let fresh {
|
||||
watchManager.cacheEncode(fresh, key: "album_\(albumId)")
|
||||
await MainActor.run { album = fresh; isLoading = false }
|
||||
}
|
||||
} catch { isLoading = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func playAlbum(shuffle: Bool) {
|
||||
guard let songs = album?.song, let first = songs.first else { return }
|
||||
if shuffle { audioPlayer.shuffleEnabled = true }
|
||||
audioPlayer.play(song: first, fromQueue: songs)
|
||||
}
|
||||
|
||||
private func downloadAll() {
|
||||
private func downloadAlbum() {
|
||||
guard let album = album else { return }
|
||||
isDownloading = true
|
||||
Task {
|
||||
|
|
@ -307,7 +516,8 @@ struct WatchAlbumDetailView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Playlists on Watch
|
||||
// MARK: - Playlists
|
||||
|
||||
struct WatchPlaylistsListView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@State private var playlists: [Playlist] = []
|
||||
|
|
@ -315,22 +525,14 @@ struct WatchPlaylistsListView: View {
|
|||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
if playlists.isEmpty && isLoading {
|
||||
ProgressView()
|
||||
} else if playlists.isEmpty {
|
||||
Text("No Playlists")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
} else {
|
||||
List(playlists) { playlist in
|
||||
NavigationLink(destination: WatchPlaylistDetailView(playlistId: playlist.id)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(playlist.name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Text("\(playlist.songCount ?? 0) songs")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
Text(playlist.name).font(.caption).lineLimit(1)
|
||||
Text("\(playlist.songCount ?? 0) songs").font(.caption2).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -338,62 +540,57 @@ struct WatchPlaylistsListView: View {
|
|||
}
|
||||
.navigationTitle("Playlists")
|
||||
.task {
|
||||
let cached = watchManager.cachedPlaylists()
|
||||
if !cached.isEmpty { playlists = cached; isLoading = false }
|
||||
do {
|
||||
playlists = try await watchManager.getPlaylists()
|
||||
isLoading = false
|
||||
let fresh = try await watchManager.getPlaylists()
|
||||
watchManager.cacheEncode(fresh, key: "playlists")
|
||||
await MainActor.run { playlists = fresh; isLoading = false }
|
||||
} catch { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playlist Detail on Watch
|
||||
struct WatchPlaylistDetailView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@EnvironmentObject var audioPlayer: WatchAudioPlayer
|
||||
@EnvironmentObject var offlineStore: WatchOfflineStore
|
||||
|
||||
let playlistId: String
|
||||
|
||||
@State private var playlist: PlaylistWithSongs?
|
||||
@State private var isLoading = true
|
||||
@State private var isDownloading = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else if let playlist = playlist {
|
||||
if let playlist = playlist, let songs = playlist.entry {
|
||||
List {
|
||||
Button(action: {
|
||||
let songs = playlist.entry ?? []
|
||||
guard let first = songs.first else { return }
|
||||
audioPlayer.play(song: first, fromQueue: songs)
|
||||
}) {
|
||||
Label("Play All", systemImage: "play.fill")
|
||||
Section {
|
||||
Button(action: downloadPlaylist) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
Text(isDownloading ? "Downloading..." : "Download All")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.pink)
|
||||
}
|
||||
|
||||
Button(action: downloadPlaylist) {
|
||||
HStack {
|
||||
if isDownloading { ProgressView().scaleEffect(0.6) }
|
||||
Label(isDownloading ? "Downloading..." : "Download All", systemImage: "arrow.down.circle")
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.disabled(isDownloading)
|
||||
}
|
||||
.disabled(isDownloading)
|
||||
|
||||
ForEach(playlist.entry ?? []) { song in
|
||||
ForEach(songs) { song in
|
||||
WatchSongRow(
|
||||
song: song,
|
||||
isPlaying: audioPlayer.currentSong?.id == song.id,
|
||||
isOffline: offlineStore.isSongAvailable(song.id)
|
||||
) {
|
||||
let songs = playlist.entry ?? []
|
||||
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
|
||||
audioPlayer.play(song: song, fromQueue: songs, at: idx)
|
||||
audioPlayer.play(song: song, fromQueue: songs)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(playlist.name)
|
||||
} else if isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
|
|
@ -408,15 +605,14 @@ struct WatchPlaylistDetailView: View {
|
|||
guard let entries = playlist?.entry else { return }
|
||||
isDownloading = true
|
||||
Task {
|
||||
for song in entries {
|
||||
await offlineStore.downloadFromServer(song: song)
|
||||
}
|
||||
for song in entries { await offlineStore.downloadFromServer(song: song) }
|
||||
await MainActor.run { isDownloading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search on Watch
|
||||
// MARK: - Search
|
||||
|
||||
struct WatchSearchView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@EnvironmentObject var audioPlayer: WatchAudioPlayer
|
||||
|
|
@ -437,16 +633,12 @@ struct WatchSearchView: View {
|
|||
if let songs = results.song, !songs.isEmpty {
|
||||
Section("Songs") {
|
||||
ForEach(songs.prefix(10)) { song in
|
||||
WatchSongRow(
|
||||
song: song,
|
||||
isPlaying: audioPlayer.currentSong?.id == song.id
|
||||
) {
|
||||
WatchSongRow(song: song, isPlaying: audioPlayer.currentSong?.id == song.id) {
|
||||
audioPlayer.play(song: song, fromQueue: songs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let albums = results.album, !albums.isEmpty {
|
||||
Section("Albums") {
|
||||
ForEach(albums.prefix(5)) { album in
|
||||
|
|
@ -469,15 +661,14 @@ struct WatchSearchView: View {
|
|||
guard !query.isEmpty else { return }
|
||||
isSearching = true
|
||||
Task {
|
||||
do {
|
||||
results = try await watchManager.search(query: query)
|
||||
isSearching = false
|
||||
} catch { isSearching = false }
|
||||
do { results = try await watchManager.search(query: query); isSearching = false }
|
||||
catch { isSearching = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reusable Song Row
|
||||
|
||||
struct WatchSongRow: View {
|
||||
let song: Song
|
||||
let isPlaying: Bool
|
||||
|
|
@ -489,14 +680,10 @@ struct WatchSongRow: View {
|
|||
HStack(spacing: 6) {
|
||||
if isPlaying {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.pink)
|
||||
.frame(width: 14)
|
||||
.font(.caption2).foregroundColor(.pink).frame(width: 14)
|
||||
} else if isOffline {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.green)
|
||||
.frame(width: 14)
|
||||
.font(.caption2).foregroundColor(.green).frame(width: 14)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
|
|
@ -504,28 +691,24 @@ struct WatchSongRow: View {
|
|||
.font(.caption)
|
||||
.foregroundColor(isPlaying ? .pink : .white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(song.artist ?? "")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
.font(.caption2).foregroundColor(.gray).lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(song.durationFormatted)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption2).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch Settings
|
||||
// MARK: - Settings
|
||||
|
||||
struct WatchSettingsView: View {
|
||||
@EnvironmentObject var watchManager: WatchSessionManager
|
||||
@EnvironmentObject var offlineStore: WatchOfflineStore
|
||||
|
||||
@State private var showAddServer = false
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -535,70 +718,46 @@ struct WatchSettingsView: View {
|
|||
ForEach(watchManager.servers) { server in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(server.name)
|
||||
.font(.caption)
|
||||
Text(server.name).font(.caption)
|
||||
if watchManager.activeServer?.id == server.id {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.pink)
|
||||
Image(systemName: "checkmark").font(.caption2).foregroundColor(.pink)
|
||||
}
|
||||
}
|
||||
Text(server.url)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
Text(server.url).font(.caption2).foregroundColor(.gray).lineLimit(1)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
watchManager.setActive(server)
|
||||
.onTapGesture { watchManager.setActive(server) }
|
||||
}
|
||||
|
||||
Button("Add Server") { showAddServer = true }.font(.caption).foregroundColor(.pink)
|
||||
Button("Sync from iPhone") { watchManager.requestServersFromPhone() }.font(.caption)
|
||||
|
||||
if watchManager.isSyncing {
|
||||
HStack {
|
||||
ProgressView().scaleEffect(0.6)
|
||||
Text("Syncing library...").font(.caption2).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Add Server") { showAddServer = true }
|
||||
.font(.caption)
|
||||
.foregroundColor(.pink)
|
||||
|
||||
Button("Sync from iPhone") {
|
||||
watchManager.requestServersFromPhone()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section("Storage") {
|
||||
HStack {
|
||||
Text("Offline Songs")
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Text("\(offlineStore.songs.count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
Text("Offline Songs").font(.caption); Spacer()
|
||||
Text("\(offlineStore.songs.count)").font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Used")
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Text(offlineStore.formattedSize)
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
Text("Used").font(.caption); Spacer()
|
||||
Text(offlineStore.formattedSize).font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
|
||||
if !offlineStore.songs.isEmpty {
|
||||
Button("Remove All Downloads", role: .destructive) {
|
||||
offlineStore.removeAll()
|
||||
}
|
||||
.font(.caption)
|
||||
Button("Remove All Downloads", role: .destructive) { offlineStore.removeAll() }.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Phone") {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(watchManager.isPhoneReachable ? .green : .orange)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(watchManager.isPhoneReachable ? "Connected" : "Not Reachable")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
Circle().fill(watchManager.isPhoneReachable ? .green : .orange).frame(width: 6, height: 6)
|
||||
Text(watchManager.isPhoneReachable ? "Connected" : "Not Reachable").font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue