mirror of
https://github.com/sudoxnym/audiobookshelf-atv.git
synced 2026-05-21 21:28:29 +00:00
Merge branch 'master' into manualSleepTimer
This commit is contained in:
commit
683b4e753a
57 changed files with 3322 additions and 561 deletions
|
|
@ -37,9 +37,9 @@ data class DeviceSettings(
|
|||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpBackwardsTime) * 1000L
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import android.os.Handler
|
|||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import java.util.*
|
||||
|
|
@ -21,7 +19,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||
|
||||
private var mediaButtonClickCount: Int = 0
|
||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
||||
var seekAmount: Long = 20000 //ms
|
||||
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||
|
|
@ -75,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
playerNotificationService.seekBackward(seekAmount)
|
||||
playerNotificationService.skipToPrevious()
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.skipToNext()
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
|
|
@ -179,10 +176,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||
handleMediaButtonClickCount()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.seekBackward(seekAmount)
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
|
|
@ -226,22 +223,22 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||
override fun handleMessage(msg: Message) {
|
||||
super.handleMessage(msg)
|
||||
if (2 == msg.what) {
|
||||
playerNotificationService.seekBackward(seekAmount)
|
||||
playerNotificationService.jumpBackward()
|
||||
playerNotificationService.play()
|
||||
}
|
||||
else if (msg.what >= 3) {
|
||||
playerNotificationService.seekForward(seekAmount)
|
||||
playerNotificationService.jumpForward()
|
||||
playerNotificationService.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example Using a custom action in android auto
|
||||
// override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||
// super.onCustomAction(action, extras)
|
||||
//
|
||||
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||
super.onCustomAction(action, extras)
|
||||
|
||||
when (action) {
|
||||
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
package com.audiobookshelf.app.player
|
||||
|
||||
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
|
||||
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||
|
|
@ -20,7 +20,6 @@ import android.util.Log
|
|||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
|
|
@ -30,9 +29,11 @@ import com.audiobookshelf.app.data.DeviceInfo
|
|||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
|
|
@ -42,6 +43,7 @@ import com.google.android.exoplayer2.upstream.*
|
|||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
|
||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||
|
||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
|
@ -294,17 +296,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||
|
||||
// Example adding custom action with icon in android auto
|
||||
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider {
|
||||
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||
// }
|
||||
// override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||
// var icon = R.drawable.exo_icon_rewind
|
||||
// return PlaybackStateCompat.CustomAction.Builder(
|
||||
// "com.audiobookshelf.app.PLAYBACK_RATE", "Playback Rate", icon)
|
||||
// .build()
|
||||
// }
|
||||
// })
|
||||
mediaSessionConnector.setCustomActionProviders(
|
||||
JumpForwardCustomActionProvider(),
|
||||
JumpBackwardCustomActionProvider(),
|
||||
)
|
||||
|
||||
mediaSession.setCallback(MediaSessionCallback(this))
|
||||
|
||||
|
|
@ -320,13 +315,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
1000 * 20 // 20s playback rebuffer
|
||||
).build()
|
||||
|
||||
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
|
||||
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
|
||||
|
||||
mPlayer = ExoPlayer.Builder(this)
|
||||
.setLoadControl(customLoadControl)
|
||||
.setSeekBackIncrementMs(seekBackTime)
|
||||
.setSeekForwardIncrementMs(seekForwardTime)
|
||||
.setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
|
||||
.setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
|
||||
.build()
|
||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||
mPlayer.addListener(PlayerListener(this))
|
||||
|
|
@ -701,6 +693,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
fun skipToPrevious() {
|
||||
currentPlayer.seekToPrevious()
|
||||
}
|
||||
|
||||
fun skipToNext() {
|
||||
currentPlayer.seekToNext()
|
||||
}
|
||||
|
||||
fun jumpForward() {
|
||||
seekForward(deviceSettings.jumpForwardTimeMs)
|
||||
}
|
||||
|
||||
fun jumpBackward() {
|
||||
seekBackward(deviceSettings.jumpBackwardsTimeMs)
|
||||
}
|
||||
|
||||
fun seekForward(amount: Long) {
|
||||
seekPlayer(getCurrentTime() + amount)
|
||||
}
|
||||
|
|
@ -757,6 +765,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
|
||||
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||
}
|
||||
|
|
@ -966,5 +977,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
|
||||
}
|
||||
}
|
||||
|
||||
inner class JumpBackwardCustomActionProvider : CustomActionProvider {
|
||||
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||
/*
|
||||
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||
responsible to reacting to a custom action.
|
||||
*/
|
||||
}
|
||||
|
||||
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||
return PlaybackStateCompat.CustomAction.Builder(
|
||||
CUSTOM_ACTION_JUMP_BACKWARD,
|
||||
getContext().getString(R.string.action_jump_backward),
|
||||
R.drawable.exo_icon_rewind
|
||||
).build()
|
||||
}
|
||||
}
|
||||
|
||||
inner class JumpForwardCustomActionProvider : CustomActionProvider {
|
||||
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||
/*
|
||||
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||
responsible to reacting to a custom action.
|
||||
*/
|
||||
}
|
||||
|
||||
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||
return PlaybackStateCompat.CustomAction.Builder(
|
||||
CUSTOM_ACTION_JUMP_FORWARD,
|
||||
getContext().getString(R.string.action_jump_forward),
|
||||
R.drawable.exo_icon_fastforward
|
||||
).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@
|
|||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||
<string name="action_jump_forward">Jump Forward</string>
|
||||
<string name="action_jump_backward">Jump Backward</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { Dialog } from '@capacitor/dialog'
|
|||
export default {
|
||||
data() {
|
||||
return {
|
||||
isReady: false,
|
||||
settingsLoaded: false,
|
||||
audioPlayerReady: false,
|
||||
stream: null,
|
||||
download: null,
|
||||
|
|
@ -151,6 +153,10 @@ export default {
|
|||
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
|
||||
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
||||
}
|
||||
|
||||
// Settings have been loaded (at least once, so it's safe to kickoff onReady)
|
||||
this.settingsLoaded = true
|
||||
this.notifyOnReady()
|
||||
},
|
||||
setListeners() {
|
||||
// if (!this.$server.socket) {
|
||||
|
|
@ -261,6 +267,18 @@ export default {
|
|||
onMediaPlayerChanged(data) {
|
||||
var mediaPlayer = data.value
|
||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
||||
},
|
||||
onReady() {
|
||||
// The UI is reporting elsewhere we are ready
|
||||
this.isReady = true
|
||||
this.notifyOnReady()
|
||||
},
|
||||
notifyOnReady() {
|
||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||
if ( this.settingsLoaded && this.isReady ) {
|
||||
AbsAudioPlayer.onReady()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -273,6 +291,7 @@ export default {
|
|||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||
|
||||
this.setListeners()
|
||||
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||
|
|
@ -292,6 +311,7 @@ export default {
|
|||
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||
// this.$server.socket.off('stream_reset', this.streamReset)
|
||||
// }
|
||||
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
|
||||
<div v-if="!isIos && userCanDownload">
|
||||
<div v-if="userCanDownload">
|
||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
|
|
@ -143,7 +143,12 @@ export default {
|
|||
},
|
||||
downloadClick() {
|
||||
if (this.downloadItem) return
|
||||
this.download()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
this.startDownload()
|
||||
} else {
|
||||
this.download()
|
||||
}
|
||||
},
|
||||
async download(selectedLocalFolder = null) {
|
||||
var localFolder = selectedLocalFolder
|
||||
|
|
@ -183,7 +188,14 @@ export default {
|
|||
}
|
||||
},
|
||||
async startDownload(localFolder) {
|
||||
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id })
|
||||
var payload = {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id
|
||||
}
|
||||
if (localFolder) {
|
||||
this.localFolderId = localFolder.id
|
||||
}
|
||||
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
|
||||
if (downloadRes && downloadRes.error) {
|
||||
var errorMsg = downloadRes.error || 'Unknown error'
|
||||
console.error('Download error', errorMsg)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,30 @@
|
|||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; };
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */; };
|
||||
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504D28AC1B0700C746DD /* AudioFile.swift */; };
|
||||
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504F28AC1B3E00C746DD /* Author.swift */; };
|
||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505128AC1B5D00C746DD /* Chapter.swift */; };
|
||||
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505328AC1B7900C746DD /* AudioTrack.swift */; };
|
||||
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505528AC1BFA00C746DD /* FileMetadata.swift */; };
|
||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505728AC1C1A00C746DD /* Library.swift */; };
|
||||
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505928AC1C4500C746DD /* Folder.swift */; };
|
||||
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505B28AC1C6200C746DD /* LibraryFile.swift */; };
|
||||
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505D28AC1C8500C746DD /* MediaProgress.swift */; };
|
||||
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */; };
|
||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506128AC1CC900C746DD /* PlayerState.swift */; };
|
||||
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */; };
|
||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */; };
|
||||
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506928AC1DF100C746DD /* LocalFile.swift */; };
|
||||
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */; };
|
||||
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */; };
|
||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; };
|
||||
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; };
|
||||
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; };
|
||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -71,7 +94,30 @@
|
|||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = "<group>"; };
|
||||
E9D5504528AC1A3900C746DD /* LibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItem.swift; sourceTree = "<group>"; };
|
||||
E9D5504728AC1A7A00C746DD /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
|
||||
E9D5504928AC1AA600C746DD /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastEpisode.swift; sourceTree = "<group>"; };
|
||||
E9D5504D28AC1B0700C746DD /* AudioFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFile.swift; sourceTree = "<group>"; };
|
||||
E9D5504F28AC1B3E00C746DD /* Author.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Author.swift; sourceTree = "<group>"; };
|
||||
E9D5505128AC1B5D00C746DD /* Chapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chapter.swift; sourceTree = "<group>"; };
|
||||
E9D5505328AC1B7900C746DD /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = "<group>"; };
|
||||
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMetadata.swift; sourceTree = "<group>"; };
|
||||
E9D5505728AC1C1A00C746DD /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = "<group>"; };
|
||||
E9D5505928AC1C4500C746DD /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
|
||||
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFile.swift; sourceTree = "<group>"; };
|
||||
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProgress.swift; sourceTree = "<group>"; };
|
||||
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackMetadata.swift; sourceTree = "<group>"; };
|
||||
E9D5506128AC1CC900C746DD /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = "<group>"; };
|
||||
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryItem.swift; sourceTree = "<group>"; };
|
||||
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPodcastEpisode.swift; sourceTree = "<group>"; };
|
||||
E9D5506928AC1DF100C746DD /* LocalFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFile.swift; sourceTree = "<group>"; };
|
||||
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMediaProgress.swift; sourceTree = "<group>"; };
|
||||
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItem.swift; sourceTree = "<group>"; };
|
||||
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = "<group>"; };
|
||||
E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = "<group>"; };
|
||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; };
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -100,6 +146,7 @@
|
|||
children = (
|
||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
|
||||
);
|
||||
path = player;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -134,9 +181,14 @@
|
|||
children = (
|
||||
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
|
||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
|
||||
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
|
||||
3A90295E280968E700E1D427 /* PlaybackReport.swift */,
|
||||
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */,
|
||||
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||
E9D5506328AC1D3F00C746DD /* server */,
|
||||
E9D5506428AC1D5800C746DD /* local */,
|
||||
E9D5506D28AC1E7400C746DD /* download */,
|
||||
);
|
||||
path = models;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -150,6 +202,7 @@
|
|||
3AF1970B2806E2590096F747 /* ApiClient.swift */,
|
||||
3AB34052280829BF0039308B /* Extensions.swift */,
|
||||
3AB34054280832720039308B /* PlayerEvents.swift */,
|
||||
E9D5507228AC218300C746DD /* DaoExtensions.swift */,
|
||||
);
|
||||
path = util;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -199,6 +252,46 @@
|
|||
name = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9D5506328AC1D3F00C746DD /* server */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9D5504528AC1A3900C746DD /* LibraryItem.swift */,
|
||||
E9D5504728AC1A7A00C746DD /* MediaType.swift */,
|
||||
E9D5504928AC1AA600C746DD /* Metadata.swift */,
|
||||
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */,
|
||||
E9D5504D28AC1B0700C746DD /* AudioFile.swift */,
|
||||
E9D5504F28AC1B3E00C746DD /* Author.swift */,
|
||||
E9D5505128AC1B5D00C746DD /* Chapter.swift */,
|
||||
E9D5505328AC1B7900C746DD /* AudioTrack.swift */,
|
||||
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */,
|
||||
E9D5505728AC1C1A00C746DD /* Library.swift */,
|
||||
E9D5505928AC1C4500C746DD /* Folder.swift */,
|
||||
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
||||
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
||||
);
|
||||
path = server;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9D5506428AC1D5800C746DD /* local */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */,
|
||||
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */,
|
||||
E9D5506928AC1DF100C746DD /* LocalFile.swift */,
|
||||
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */,
|
||||
);
|
||||
path = local;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9D5506D28AC1E7400C746DD /* download */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */,
|
||||
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */,
|
||||
);
|
||||
path = download;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -312,28 +405,51 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
||||
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
||||
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */,
|
||||
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */,
|
||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
|
||||
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
|
||||
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
||||
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
||||
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
|
||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
|
||||
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */,
|
||||
3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
|
||||
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -482,7 +598,7 @@
|
|||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.56;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
|
|
@ -506,7 +622,7 @@
|
|||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.56;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -11,7 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
migrationBlock: { migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
NSLog("Realm schema version was \(oldSchemaVersion)")
|
||||
|
|
@ -34,18 +34,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
NSLog("Audiobookself is now in the background")
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
NSLog("Audiobookself is now in the foreground")
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
NSLog("Audiobookself is now active")
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
NSLog("Audiobookself is terminating")
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
#import <Capacitor/Capacitor.h>
|
||||
|
||||
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
||||
CAP_PLUGIN_METHOD(onReady, CAPPluginReturnNone);
|
||||
|
||||
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@
|
|||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import RealmSwift
|
||||
|
||||
@objc(AbsAudioPlayer)
|
||||
public class AbsAudioPlayer: CAPPlugin {
|
||||
private var initialPlayWhenReady = false
|
||||
private var initialPlaybackRate:Float = 1
|
||||
private var isUIReady = false
|
||||
|
||||
override public func load() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
|
|
@ -21,10 +22,41 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||
|
||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||
|
||||
}
|
||||
|
||||
@objc func onReady(_ call: CAPPluginCall) {
|
||||
Task { await self.restorePlaybackSession() }
|
||||
}
|
||||
|
||||
func restorePlaybackSession() async {
|
||||
// We don't need to restore if we have an active session
|
||||
guard PlayerHandler.getPlaybackSession() == nil else { return }
|
||||
|
||||
do {
|
||||
// Fetch the most recent active session
|
||||
let activeSession = try await Realm().objects(PlaybackSession.self).where({ $0.isActiveSession == true }).last?.freeze()
|
||||
if let activeSession = activeSession {
|
||||
await PlayerProgress.syncFromServer()
|
||||
try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate)
|
||||
}
|
||||
} catch {
|
||||
NSLog("Failed to restore playback session")
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func startPlaybackSession(_ session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) throws {
|
||||
guard let libraryItemId = session.libraryItemId else { throw PlayerError.libraryItemIdNotSpecified }
|
||||
|
||||
self.sendPrepareMetadataEvent(itemId: libraryItemId, playWhenReady: playWhenReady)
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
self.sendMetadata()
|
||||
}
|
||||
|
||||
@objc func prepareLibraryItem(_ call: CAPPluginCall) {
|
||||
let libraryItemId = call.getString("libraryItemId")
|
||||
|
|
@ -36,32 +68,42 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||
NSLog("provide library item id")
|
||||
return call.resolve()
|
||||
}
|
||||
if libraryItemId!.starts(with: "local") {
|
||||
NSLog("local items are not implemnted")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
initialPlayWhenReady = playWhenReady
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
PlayerHandler.stopPlayback()
|
||||
|
||||
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
||||
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
|
||||
if (isLocalItem) {
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
||||
NSLog("Failed to get local playback session")
|
||||
return call.resolve([:])
|
||||
}
|
||||
playbackSession.save()
|
||||
|
||||
do {
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
call.resolve(try session.asDictionary())
|
||||
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try playbackSession.asDictionary())
|
||||
} catch(let exception) {
|
||||
NSLog("failed to convert session to json")
|
||||
NSLog("Failed to start session")
|
||||
debugPrint(exception)
|
||||
call.resolve([:])
|
||||
}
|
||||
|
||||
|
||||
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
self.sendMetadata()
|
||||
} else { // Playing from the server
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
|
||||
session.save()
|
||||
do {
|
||||
try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try session.asDictionary())
|
||||
} catch(let exception) {
|
||||
NSLog("Failed to start session")
|
||||
debugPrint(exception)
|
||||
call.resolve([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||
NSLog("Close playback")
|
||||
|
||||
|
|
@ -76,7 +118,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||
])
|
||||
}
|
||||
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
||||
PlayerHandler.setPlaybackSpeed(speed: call.getFloat("value", 1.0))
|
||||
let playbackRate = call.getFloat("value", 1.0)
|
||||
let settings = PlayerSettings.main()
|
||||
settings.update {
|
||||
settings.playbackRate = playbackRate
|
||||
}
|
||||
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +156,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||
|
||||
@objc func sendMetadata() {
|
||||
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
||||
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata())
|
||||
if let metadata = PlayerHandler.getMetdata() {
|
||||
self.notifyListeners("onMetadata", data: metadata)
|
||||
}
|
||||
}
|
||||
@objc func sendPlaybackClosedEvent() {
|
||||
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
||||
|
|
@ -167,22 +216,32 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||
"value": PlayerHandler.getCurrentTime()
|
||||
])
|
||||
}
|
||||
|
||||
@objc func sendSleepTimerSet() {
|
||||
self.notifyListeners("onSleepTimerSet", data: [
|
||||
"value": PlayerHandler.remainingSleepTime
|
||||
])
|
||||
}
|
||||
|
||||
@objc func onLocalMediaProgressUpdate() {
|
||||
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
||||
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
||||
NSLog("Sending local progress back to the UI")
|
||||
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||
}
|
||||
|
||||
@objc func onPlaybackFailed() {
|
||||
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
||||
let playbackSession = PlayerHandler.getPlaybackSession()
|
||||
let libraryItemId = playbackSession?.libraryItemId ?? ""
|
||||
let episodeId = playbackSession?.episodeId ?? nil
|
||||
let session = PlayerHandler.getPlaybackSession()
|
||||
let libraryItemId = session?.libraryItemId ?? ""
|
||||
let episodeId = session?.episodeId ?? nil
|
||||
NSLog("Forcing Transcode")
|
||||
|
||||
// If direct playing then fallback to transcode
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
|
||||
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
|
||||
session.save()
|
||||
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate)
|
||||
|
||||
do {
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
|
|
@ -207,7 +266,12 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||
"playWhenReady": playWhenReady,
|
||||
])
|
||||
}
|
||||
|
||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||
self.notifyListeners("onPlaybackSession", data: session)
|
||||
}
|
||||
}
|
||||
|
||||
enum PlayerError: String, Error {
|
||||
case libraryItemIdNotSpecified = "No libraryItemId provided on session"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
|
|||
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import Foundation
|
||||
import Capacitor
|
||||
import RealmSwift
|
||||
import SwiftUI
|
||||
|
||||
extension String {
|
||||
|
||||
|
|
@ -35,13 +36,14 @@ public class AbsDatabase: CAPPlugin {
|
|||
let token = call.getString("token", "")
|
||||
|
||||
let name = "\(address) (\(username))"
|
||||
let config = ServerConnectionConfig()
|
||||
|
||||
if id == nil {
|
||||
id = "\(address)@\(username)".toBase64()
|
||||
}
|
||||
|
||||
config.id = id!
|
||||
let config = ServerConnectionConfig()
|
||||
config.id = id ?? ""
|
||||
config.index = 1
|
||||
config.name = name
|
||||
config.address = address
|
||||
config.userId = userId
|
||||
|
|
@ -75,20 +77,152 @@ public class AbsDatabase: CAPPlugin {
|
|||
}
|
||||
|
||||
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
||||
call.resolve([ "value": [] ])
|
||||
do {
|
||||
let items = Database.shared.getLocalLibraryItems()
|
||||
call.resolve([ "value": try items.asDictionaryArray()])
|
||||
} catch(let exception) {
|
||||
NSLog("error while readling local library items")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
call.resolve()
|
||||
do {
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
||||
switch item {
|
||||
case .some(let foundItem):
|
||||
call.resolve(try foundItem.asDictionary())
|
||||
default:
|
||||
call.resolve()
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("error while readling local library items")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
||||
call.resolve()
|
||||
do {
|
||||
let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
|
||||
switch item {
|
||||
case .some(let foundItem):
|
||||
call.resolve(try foundItem.asDictionary())
|
||||
default:
|
||||
call.resolve()
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("error while readling local library items")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
||||
call.resolve([ "value": [] ])
|
||||
}
|
||||
|
||||
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
||||
call.resolve([ "value": [] ])
|
||||
do {
|
||||
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||
} catch {
|
||||
NSLog("Error while loading local media progress")
|
||||
debugPrint(error)
|
||||
call.resolve(["value": []])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func removeLocalMediaProgress(_ call: CAPPluginCall) {
|
||||
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||
guard let localMediaProgressId = localMediaProgressId else {
|
||||
call.reject("localMediaProgressId not specificed")
|
||||
return
|
||||
}
|
||||
Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
|
||||
guard Store.serverConfig != nil else {
|
||||
call.reject("syncLocalMediaProgressWithServer not connected to server")
|
||||
return
|
||||
}
|
||||
ApiClient.syncMediaProgress { results in
|
||||
do {
|
||||
call.resolve(try results.asDictionary())
|
||||
} catch {
|
||||
call.reject("Failed to report synced media progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) {
|
||||
let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self)
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localEpisodeId = call.getString("localEpisodeId")
|
||||
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||
|
||||
do {
|
||||
guard let serverMediaProgress = serverMediaProgress else {
|
||||
return call.reject("serverMediaProgress not specified")
|
||||
}
|
||||
guard localLibraryItemId != nil || localMediaProgressId != nil else {
|
||||
return call.reject("localLibraryItemId or localMediaProgressId must be specified")
|
||||
}
|
||||
|
||||
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||
guard let localMediaProgress = localMediaProgress else {
|
||||
call.reject("Local media progress not found or created")
|
||||
return
|
||||
}
|
||||
localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
||||
|
||||
NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
|
||||
Database.shared.saveLocalMediaProgress(localMediaProgress)
|
||||
call.resolve(try localMediaProgress.asDictionary())
|
||||
} catch {
|
||||
call.reject("Failed to sync media progress")
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localEpisodeId = call.getString("localEpisodeId")
|
||||
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||
let isFinished = call.getBool("isFinished", false)
|
||||
|
||||
NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)")
|
||||
|
||||
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||
guard let localMediaProgress = localMediaProgress else {
|
||||
call.resolve(["error": "Library Item not found"])
|
||||
return
|
||||
}
|
||||
|
||||
// Update finished status
|
||||
localMediaProgress.updateIsFinished(isFinished)
|
||||
Database.shared.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
// Build API response
|
||||
let progressDictionary = try? localMediaProgress.asDictionary()
|
||||
var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
|
||||
|
||||
// Send update to the server if logged in
|
||||
let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil
|
||||
let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId
|
||||
if hasLinkedServer && loggedIntoServer {
|
||||
response["server"] = true
|
||||
let payload = ["isFinished": isFinished]
|
||||
ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) {
|
||||
call.resolve(response)
|
||||
}
|
||||
} else {
|
||||
call.resolve(response)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||
let enableAltView = call.getBool("enableAltView") ?? false
|
||||
|
|
|
|||
|
|
@ -7,65 +7,382 @@
|
|||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import RealmSwift
|
||||
|
||||
@objc(AbsDownloader)
|
||||
public class AbsDownloader: CAPPlugin {
|
||||
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
||||
let libraryItemId = call.getString("libraryItemId")
|
||||
let episodeId = call.getString("episodeId")
|
||||
|
||||
NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")")
|
||||
|
||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in
|
||||
if (libraryItem == nil) {
|
||||
NSLog("Library item not found")
|
||||
call.resolve()
|
||||
} else {
|
||||
NSLog("Got library item \(libraryItem!)")
|
||||
|
||||
// TODO: break out in seperate functions
|
||||
libraryItem!.media.tracks?.forEach { track in
|
||||
NSLog("TRACK \(track.contentUrl!)")
|
||||
// filename needs to be encoded otherwise would just use contentUrl
|
||||
let filename = track.metadata?.filename ?? ""
|
||||
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
let url = URL(string: urlstr)!
|
||||
|
||||
|
||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItemId!)")
|
||||
NSLog("ITEM DIR \(itemDirectory)")
|
||||
|
||||
// Create library item directory
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false)
|
||||
} catch {
|
||||
NSLog("Failed to CREATE LI DIRECTORY \(error)")
|
||||
}
|
||||
|
||||
// Output filename
|
||||
let trackFilename = itemDirectory.appendingPathComponent("\(filename)")
|
||||
|
||||
let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in
|
||||
|
||||
guard let fileURL = urlOrNil else { return }
|
||||
|
||||
do {
|
||||
NSLog("Download TMP file URL \(fileURL)")
|
||||
let imageData = try Data(contentsOf:fileURL)
|
||||
try imageData.write(to: trackFilename)
|
||||
NSLog("Download written to \(trackFilename)")
|
||||
} catch {
|
||||
NSLog("FILE ERROR: \(error)")
|
||||
}
|
||||
}
|
||||
downloadTask.resume()
|
||||
}
|
||||
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
|
||||
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let queue = OperationQueue()
|
||||
queue.maxConcurrentOperationCount = 5
|
||||
return URLSession(configuration: .default, delegate: self, delegateQueue: queue)
|
||||
}()
|
||||
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||
private var downloadItemProgress = [String: DownloadItem]()
|
||||
private var monitoringProgressTimer: Timer?
|
||||
|
||||
|
||||
// MARK: - Progress handling
|
||||
|
||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||
let realm = try! Realm()
|
||||
try realm.write {
|
||||
downloadItemPart.progress = 100
|
||||
downloadItemPart.completed = true
|
||||
}
|
||||
|
||||
|
||||
call.resolve()
|
||||
do {
|
||||
// Move the downloaded file into place
|
||||
guard let destinationUrl = downloadItemPart.destinationURL else {
|
||||
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||
}
|
||||
try? FileManager.default.removeItem(at: destinationUrl)
|
||||
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||
try realm.write {
|
||||
downloadItemPart.moved = true
|
||||
}
|
||||
} catch {
|
||||
try realm.write {
|
||||
downloadItemPart.failed = true
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||
if let error = error {
|
||||
try Realm().write {
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||
// Calculate the download percentage
|
||||
let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100
|
||||
// Only update the progress if we received accurate progress data
|
||||
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||
try Realm().write {
|
||||
downloadItemPart.progress = percentDownloaded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||
do {
|
||||
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
||||
NSLog("Received download update for \(downloadItemPartId)")
|
||||
|
||||
// Find the download item
|
||||
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
||||
guard var downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound }
|
||||
|
||||
// Find the download item part
|
||||
let part = downloadItem.downloadItemParts.first(where: { $0.id == downloadItemPartId })
|
||||
guard let part = part else { throw LibraryItemDownloadError.downloadItemPartNotFound }
|
||||
|
||||
// Call the progress handler
|
||||
do {
|
||||
try progressHandler(downloadItem, part)
|
||||
} catch {
|
||||
NSLog("Error while processing progress")
|
||||
debugPrint(error)
|
||||
}
|
||||
|
||||
// Update the progress
|
||||
downloadItem = downloadItem.freeze()
|
||||
self.progressStatusQueue.async(flags: .barrier) {
|
||||
self.downloadItemProgress.updateValue(downloadItem, forKey: downloadItem.id!)
|
||||
}
|
||||
self.notifyDownloadProgress()
|
||||
} catch {
|
||||
NSLog("DownloadItemError")
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
|
||||
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
|
||||
private func notifyDownloadProgress() {
|
||||
if self.monitoringProgressTimer?.isValid ?? false {
|
||||
NSLog("Already monitoring progress, no need to start timer again")
|
||||
} else {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in
|
||||
NSLog("Starting monitoring download progress...")
|
||||
|
||||
// Fetch active downloads in a thread-safe way
|
||||
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
||||
self.progressStatusQueue.sync {
|
||||
let activeDownloads = self.downloadItemProgress
|
||||
if activeDownloads.isEmpty {
|
||||
NSLog("Finishing monitoring download progress...")
|
||||
t.invalidate()
|
||||
}
|
||||
return activeDownloads
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a completed download item in a thread-safe way
|
||||
func handleDoneDownloadItem(_ item: DownloadItem) {
|
||||
self.progressStatusQueue.async(flags: .barrier) {
|
||||
self.downloadItemProgress.removeValue(forKey: item.id!)
|
||||
}
|
||||
self.handleDownloadTaskCompleteFromDownloadItem(item)
|
||||
if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) {
|
||||
item.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// Emit status for active downloads
|
||||
if let activeDownloads = fetchActiveDownloads() {
|
||||
for item in activeDownloads.values {
|
||||
try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary())
|
||||
if item.isDoneDownloading() { handleDoneDownloadItem(item) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) {
|
||||
var statusNotification = [String: Any]()
|
||||
statusNotification["libraryItemId"] = downloadItem.id
|
||||
|
||||
if ( downloadItem.didDownloadSuccessfully() ) {
|
||||
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in
|
||||
guard let libraryItem = libraryItem else { NSLog("LibraryItem not found"); return }
|
||||
let localDirectory = libraryItem.id
|
||||
var coverFile: String?
|
||||
|
||||
// Assemble the local library item
|
||||
let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in
|
||||
var mimeType = part.mimeType()
|
||||
if part.filename == "cover.jpg" {
|
||||
coverFile = part.destinationUri
|
||||
mimeType = "image/jpg"
|
||||
}
|
||||
return LocalFile(libraryItem.id, part.filename!, mimeType!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize))
|
||||
}
|
||||
var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: libraryItem.id)
|
||||
if (localLibraryItem != nil && localLibraryItem!.isPodcast) {
|
||||
try? Realm().write {
|
||||
try? localLibraryItem?.addFiles(files, item: libraryItem)
|
||||
}
|
||||
} else {
|
||||
localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile)
|
||||
Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!)
|
||||
}
|
||||
|
||||
statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary()
|
||||
|
||||
if let progress = libraryItem.userMediaProgress {
|
||||
let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId })
|
||||
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress)
|
||||
Database.shared.saveLocalMediaProgress(localMediaProgress)
|
||||
statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary()
|
||||
}
|
||||
|
||||
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
|
||||
}
|
||||
} else {
|
||||
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Capacitor functions
|
||||
|
||||
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
|
||||
let libraryItemId = call.getString("libraryItemId")
|
||||
var episodeId = call.getString("episodeId")
|
||||
if ( episodeId == "null" ) { episodeId = nil }
|
||||
|
||||
NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
|
||||
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
|
||||
|
||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in
|
||||
if let libraryItem = libraryItem {
|
||||
NSLog("Got library item from server \(libraryItem.id)")
|
||||
do {
|
||||
if let episodeId = episodeId {
|
||||
// Download a podcast episode
|
||||
guard libraryItem.mediaType == "podcast" else { throw LibraryItemDownloadError.libraryItemNotPodcast }
|
||||
let episode = libraryItem.media?.episodes.enumerated().first(where: { $1.id == episodeId })?.element
|
||||
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
|
||||
try self.startLibraryItemDownload(libraryItem, episode: episode)
|
||||
} else {
|
||||
// Download a book
|
||||
try self.startLibraryItemDownload(libraryItem)
|
||||
}
|
||||
call.resolve()
|
||||
} catch {
|
||||
debugPrint(error)
|
||||
call.resolve(["error": "Failed to download"])
|
||||
}
|
||||
} else {
|
||||
call.resolve(["error": "Server request failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startLibraryItemDownload(_ item: LibraryItem) throws {
|
||||
try startLibraryItemDownload(item, episode: nil)
|
||||
}
|
||||
|
||||
private func startLibraryItemDownload(_ item: LibraryItem, episode: PodcastEpisode?) throws {
|
||||
let tracks = List<AudioTrack>()
|
||||
var episodeId: String?
|
||||
|
||||
// Handle the different media type downloads
|
||||
switch item.mediaType {
|
||||
case "book":
|
||||
guard item.media?.tracks.count ?? 0 > 0 else { throw LibraryItemDownloadError.noTracks }
|
||||
item.media?.tracks.forEach { t in tracks.append(AudioTrack.detachCopy(of: t)!) }
|
||||
case "podcast":
|
||||
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
|
||||
guard let podcastTrack = episode.audioTrack else { throw LibraryItemDownloadError.noTracks }
|
||||
episodeId = episode.id
|
||||
tracks.append(AudioTrack.detachCopy(of: podcastTrack)!)
|
||||
default:
|
||||
throw LibraryItemDownloadError.unknownMediaType
|
||||
}
|
||||
|
||||
// Queue up everything for downloading
|
||||
let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!)
|
||||
var tasks = [DownloadItemPartTask]()
|
||||
for (i, track) in tracks.enumerated() {
|
||||
let task = try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode)
|
||||
downloadItem.downloadItemParts.append(task.part)
|
||||
tasks.append(task)
|
||||
}
|
||||
|
||||
// Also download the cover
|
||||
if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) {
|
||||
if let task = try? startLibraryItemCoverDownload(item: item) {
|
||||
downloadItem.downloadItemParts.append(task.part)
|
||||
tasks.append(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist in the database before status start coming in
|
||||
Database.shared.saveDownloadItem(downloadItem)
|
||||
|
||||
// Start all the downloads
|
||||
for task in tasks {
|
||||
task.task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||
NSLog("TRACK \(track.contentUrl!)")
|
||||
|
||||
// If we don't name metadata, then we can't proceed
|
||||
guard let filename = track.metadata?.filename else {
|
||||
throw LibraryItemDownloadError.noMetadata
|
||||
}
|
||||
|
||||
let serverUrl = urlForTrack(item: item, track: track)
|
||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
let task = session.downloadTask(with: serverUrl)
|
||||
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode)
|
||||
|
||||
// Store the id on the task so the download item can be pulled from the database later
|
||||
task.taskDescription = part.id
|
||||
|
||||
return DownloadItemPartTask(part: part, task: task)
|
||||
}
|
||||
|
||||
private func startLibraryItemCoverDownload(item: LibraryItem) throws -> DownloadItemPartTask {
|
||||
let filename = "cover.jpg"
|
||||
let serverPath = "/api/items/\(item.id)/cover"
|
||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil)
|
||||
let task = session.downloadTask(with: part.downloadURL!)
|
||||
|
||||
// Store the id on the task so the download item can be pulled from the database later
|
||||
task.taskDescription = part.id
|
||||
|
||||
return DownloadItemPartTask(part: part, task: task)
|
||||
}
|
||||
|
||||
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
||||
// filename needs to be encoded otherwise would just use contentUrl
|
||||
let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
return URL(string: urlstr)!
|
||||
}
|
||||
|
||||
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
||||
let itemDirectory = item.id
|
||||
NSLog("ITEM DIR \(itemDirectory)")
|
||||
|
||||
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
||||
NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)")
|
||||
throw LibraryItemDownloadError.failedDirectory
|
||||
}
|
||||
|
||||
return itemDirectory
|
||||
}
|
||||
|
||||
static func itemDownloadFolder(path: String) -> URL? {
|
||||
do {
|
||||
var itemFolder = AbsDownloader.downloadsDirectory.appendingPathComponent(path)
|
||||
|
||||
if !FileManager.default.fileExists(atPath: itemFolder.path) {
|
||||
try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// Make sure we don't backup download files to iCloud
|
||||
var resourceValues = URLResourceValues()
|
||||
resourceValues.isExcludedFromBackup = true
|
||||
try itemFolder.setResourceValues(resourceValues)
|
||||
|
||||
return itemFolder
|
||||
} catch {
|
||||
NSLog("Failed to CREATE LI DIRECTORY \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Class structs
|
||||
|
||||
typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: DownloadItemPart) throws -> Void
|
||||
|
||||
struct DownloadItemPartTask {
|
||||
let part: DownloadItemPart
|
||||
let task: URLSessionDownloadTask
|
||||
}
|
||||
|
||||
enum LibraryItemDownloadError: String, Error {
|
||||
case noTracks = "No tracks on library item"
|
||||
case noMetadata = "No metadata for track, unable to download"
|
||||
case libraryItemNotPodcast = "Library item is not a podcast but episode was requested"
|
||||
case podcastEpisodeNotFound = "Invalid podcast episode not found"
|
||||
case podcastOnlySupported = "Only podcasts are supported for this function"
|
||||
case unknownMediaType = "Unknown media type"
|
||||
case failedDirectory = "Failed to create directory"
|
||||
case failedDownload = "Failed to download item"
|
||||
case noTaskDescription = "No task description"
|
||||
case downloadItemNotFound = "DownloadItem not found"
|
||||
case downloadItemPartNotFound = "DownloadItemPart not found"
|
||||
case downloadItemPartDestinationUrlNotDefined = "DownloadItemPart destination URL not defined"
|
||||
case libraryItemNotFound = "LibraryItem not found for id"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,79 +13,105 @@ public class AbsFileSystem: CAPPlugin {
|
|||
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||
let mediaType = call.getString("mediaType")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||
let folderUrl = call.getString("folderUrl")
|
||||
|
||||
// TODO: Is this even necessary on iOS?
|
||||
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||
|
||||
call.resolve([
|
||||
"value": true
|
||||
])
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func scanFolder(_ call: CAPPluginCall) {
|
||||
let folderId = call.getString("folderId")
|
||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||
let folderId = call.getString("folderId")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("removeFolder \(folderId ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||
|
||||
call.resolve()
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
|
||||
@objc func deleteItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localLibraryItemId = call.getString("id")
|
||||
let contentUrl = call.getString("contentUrl")
|
||||
|
||||
// TODO: Implement
|
||||
|
||||
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
var success = false
|
||||
do {
|
||||
if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||
try FileManager.default.removeItem(at: item.contentDirectory!)
|
||||
item.delete()
|
||||
success = true
|
||||
}
|
||||
} catch {
|
||||
NSLog("Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
|
||||
call.resolve(["success": success])
|
||||
}
|
||||
|
||||
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localLibraryItemId = call.getString("id")
|
||||
let trackLocalFileId = call.getString("trackLocalFileId")
|
||||
let contentUrl = call.getString("contentUrl")
|
||||
|
||||
// TODO: Implement
|
||||
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
||||
|
||||
call.resolve()
|
||||
var success = false
|
||||
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||
item.update {
|
||||
do {
|
||||
if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) {
|
||||
try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath)
|
||||
item.realm?.delete(item.localFiles[fileIndex])
|
||||
if item.isPodcast, let media = item.media {
|
||||
if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) {
|
||||
media.episodes.remove(at: episodeIndex)
|
||||
}
|
||||
item.media = media
|
||||
}
|
||||
call.resolve(try item.asDictionary())
|
||||
success = true
|
||||
}
|
||||
} catch {
|
||||
NSLog("Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !success {
|
||||
call.resolve(["success": success])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
//
|
||||
// DataClasses.swift
|
||||
// App
|
||||
//
|
||||
// Created by Benonymity on 4/20/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreMedia
|
||||
|
||||
struct LibraryItem: Codable {
|
||||
var id: String
|
||||
var ino:String
|
||||
var libraryId: String
|
||||
var folderId: String
|
||||
var path: String
|
||||
var relPath: String
|
||||
var isFile: Bool
|
||||
var mtimeMs: Int64
|
||||
var ctimeMs: Int64
|
||||
var birthtimeMs: Int64
|
||||
var addedAt: Int64
|
||||
var updatedAt: Int64
|
||||
var lastScan: Int64?
|
||||
var scanVersion: String?
|
||||
var isMissing: Bool
|
||||
var isInvalid: Bool
|
||||
var mediaType: String
|
||||
var media: MediaType
|
||||
var libraryFiles: [LibraryFile]
|
||||
var userMediaProgress:MediaProgress?
|
||||
}
|
||||
struct MediaType: Codable {
|
||||
var libraryItemId: String?
|
||||
var metadata: Metadata
|
||||
var coverPath: String?
|
||||
var tags: [String]?
|
||||
var audioFiles: [AudioTrack]?
|
||||
var chapters: [Chapter]?
|
||||
var tracks: [AudioTrack]?
|
||||
var size: Int64?
|
||||
var duration: Double?
|
||||
var episodes: [PodcastEpisode]?
|
||||
var autoDownloadEpisodes: Bool?
|
||||
}
|
||||
struct Metadata: Codable {
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var authors: [Author]?
|
||||
var narrators: [String]?
|
||||
var genres: [String]
|
||||
var publishedYear: String?
|
||||
var publishedDate: String?
|
||||
var publisher: String?
|
||||
var description: String?
|
||||
var isbn: String?
|
||||
var asin: String?
|
||||
var language: String?
|
||||
var explicit: Bool
|
||||
var authorName: String?
|
||||
var authorNameLF: String?
|
||||
var narratorName: String?
|
||||
var seriesName: String?
|
||||
var feedUrl: String?
|
||||
}
|
||||
struct PodcastEpisode: Codable {
|
||||
var id: String
|
||||
var index: Int
|
||||
var episode: String?
|
||||
var episodeType: String?
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var description: String?
|
||||
var audioFile: AudioFile?
|
||||
var audioTrack: AudioTrack?
|
||||
var duration: Double
|
||||
var size: Int64
|
||||
// var serverEpisodeId: String?
|
||||
}
|
||||
struct AudioFile: Codable {
|
||||
var index: Int
|
||||
var ino: String
|
||||
var metadata: FileMetadata
|
||||
}
|
||||
struct Author: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var coverPath: String?
|
||||
}
|
||||
struct Chapter: Codable {
|
||||
var id: Int
|
||||
var start: Double
|
||||
var end: Double
|
||||
var title: String?
|
||||
}
|
||||
struct AudioTrack: Codable {
|
||||
var index: Int?
|
||||
var startOffset: Double?
|
||||
var duration: Double
|
||||
var title: String?
|
||||
var contentUrl: String?
|
||||
var mimeType: String
|
||||
var metadata: FileMetadata?
|
||||
// var isLocal: Bool
|
||||
// var localFileId: String?
|
||||
// var audioProbeResult: AudioProbeResult? Needed for local playback
|
||||
var serverIndex: Int?
|
||||
}
|
||||
struct FileMetadata: Codable {
|
||||
var filename: String
|
||||
var ext: String
|
||||
var path: String
|
||||
var relPath: String
|
||||
}
|
||||
struct Library: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var folders: [Folder]
|
||||
var icon: String
|
||||
var mediaType: String
|
||||
}
|
||||
struct Folder: Codable {
|
||||
var id: String
|
||||
var fullPath: String
|
||||
}
|
||||
struct LibraryFile: Codable {
|
||||
var ino: String
|
||||
var metadata: FileMetadata
|
||||
}
|
||||
struct MediaProgress:Codable {
|
||||
var id:String
|
||||
var libraryItemId:String
|
||||
var episodeId:String?
|
||||
var duration:Double
|
||||
var progress:Double
|
||||
var currentTime:Double
|
||||
var isFinished:Bool
|
||||
var lastUpdate:Int64
|
||||
var startedAt:Int64
|
||||
var finishedAt:Int64?
|
||||
}
|
||||
|
|
@ -9,28 +9,21 @@ import Foundation
|
|||
import RealmSwift
|
||||
|
||||
class DeviceSettings: Object {
|
||||
@Persisted var disableAutoRewind: Bool
|
||||
@Persisted var enableAltView: Bool
|
||||
@Persisted var jumpBackwardsTime: Int
|
||||
@Persisted var jumpForwardTime: Int
|
||||
@Persisted var disableAutoRewind: Bool = false
|
||||
@Persisted var enableAltView: Bool = false
|
||||
@Persisted var jumpBackwardsTime: Int = 10
|
||||
@Persisted var jumpForwardTime: Int = 10
|
||||
}
|
||||
|
||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = false
|
||||
settings.enableAltView = false
|
||||
settings.jumpForwardTime = 10
|
||||
settings.jumpBackwardsTime = 10
|
||||
return settings
|
||||
return DeviceSettings()
|
||||
}
|
||||
|
||||
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||
return Database.realmQueue.sync {
|
||||
return [
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"jumpForwardTime": settings.jumpForwardTime
|
||||
]
|
||||
}
|
||||
return [
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"jumpForwardTime": settings.jumpForwardTime
|
||||
]
|
||||
}
|
||||
|
|
|
|||
14
ios/App/Shared/models/PlaybackMetadata.swift
Normal file
14
ios/App/Shared/models/PlaybackMetadata.swift
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// PlaybackMetadata.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class PlaybackMetadata: Codable {
|
||||
var duration: Double = 0
|
||||
var currentTime: Double = 0
|
||||
var playerState: PlayerState = PlayerState.IDLE
|
||||
}
|
||||
|
|
@ -6,27 +6,155 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
struct PlaybackSession: Decodable, Encodable {
|
||||
var id: String
|
||||
var userId: String?
|
||||
var libraryItemId: String?
|
||||
var episodeId: String?
|
||||
var mediaType: String
|
||||
// var mediaMetadata: MediaTypeMetadata - It is not implemented in android?
|
||||
var chapters: [Chapter]
|
||||
var displayTitle: String?
|
||||
var displayAuthor: String?
|
||||
var coverPath: String?
|
||||
var duration: Double
|
||||
var playMethod: Int
|
||||
var startedAt: Double?
|
||||
var updatedAt: Double?
|
||||
var timeListening: Double
|
||||
var audioTracks: [AudioTrack]
|
||||
var currentTime: Double
|
||||
var libraryItem: LibraryItem
|
||||
// var localLibraryItem: LocalLibraryItem?
|
||||
var serverConnectionConfigId: String?
|
||||
var serverAddress: String?
|
||||
class PlaybackSession: Object, Codable, Deletable {
|
||||
@Persisted(primaryKey: true) var id: String = ""
|
||||
@Persisted var userId: String?
|
||||
@Persisted var libraryItemId: String?
|
||||
@Persisted var episodeId: String?
|
||||
@Persisted var mediaType: String = ""
|
||||
@Persisted var mediaMetadata: Metadata?
|
||||
@Persisted var chapters = List<Chapter>()
|
||||
@Persisted var displayTitle: String?
|
||||
@Persisted var displayAuthor: String?
|
||||
@Persisted var coverPath: String?
|
||||
@Persisted var duration: Double = 0
|
||||
@Persisted var playMethod: Int = PlayMethod.directplay.rawValue
|
||||
@Persisted var startedAt: Double?
|
||||
@Persisted var updatedAt: Double?
|
||||
@Persisted var timeListening: Double = 0
|
||||
@Persisted var audioTracks = List<AudioTrack>()
|
||||
@Persisted var currentTime: Double = 0
|
||||
@Persisted var libraryItem: LibraryItem?
|
||||
@Persisted var localLibraryItem: LocalLibraryItem?
|
||||
@Persisted var serverConnectionConfigId: String?
|
||||
@Persisted var serverAddress: String?
|
||||
@Persisted var isActiveSession = true
|
||||
|
||||
var isLocal: Bool { self.localLibraryItem != nil }
|
||||
var mediaPlayer: String { "AVPlayer" }
|
||||
var deviceInfo: [String: String?]? {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
|
||||
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||
ptr in String.init(validatingUTF8: ptr)
|
||||
}
|
||||
}
|
||||
return [
|
||||
"manufacturer": "Apple",
|
||||
"model": modelCode,
|
||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
]
|
||||
}
|
||||
|
||||
var localMediaProgressId: String? {
|
||||
if let localLibraryItem = localLibraryItem, let episodeId = episodeId {
|
||||
return "\(localLibraryItem.id)-\(episodeId)"
|
||||
} else if let localLibraryItem = localLibraryItem {
|
||||
return localLibraryItem.id
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var totalDuration: Double {
|
||||
var total = 0.0
|
||||
self.audioTracks.forEach { total += $0.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
var progress: Double { self.currentTime / self.totalDuration }
|
||||
|
||||
internal init(id: String, userId: String? = nil, libraryItemId: String? = nil, episodeId: String? = nil, mediaType: String, mediaMetadata: Metadata?, chapters: List<Chapter> = List<Chapter>(), displayTitle: String? = nil, displayAuthor: String? = nil, coverPath: String? = nil, duration: Double, playMethod: Int, startedAt: Double? = nil, updatedAt: Double? = nil, timeListening: Double, audioTracks: List<AudioTrack> = List<AudioTrack>(), currentTime: Double, libraryItem: LibraryItem? = nil, localLibraryItem: LocalLibraryItem? = nil, serverConnectionConfigId: String? = nil, serverAddress: String? = nil) {
|
||||
self.id = id
|
||||
self.userId = userId
|
||||
self.libraryItemId = libraryItemId
|
||||
self.episodeId = episodeId
|
||||
self.mediaType = mediaType
|
||||
self.mediaMetadata = mediaMetadata
|
||||
self.chapters = chapters
|
||||
self.displayTitle = displayTitle
|
||||
self.displayAuthor = displayAuthor
|
||||
self.coverPath = coverPath
|
||||
self.duration = duration
|
||||
self.playMethod = playMethod
|
||||
self.startedAt = startedAt
|
||||
self.updatedAt = updatedAt
|
||||
self.timeListening = timeListening
|
||||
self.audioTracks = audioTracks
|
||||
self.currentTime = currentTime
|
||||
self.libraryItem = libraryItem
|
||||
self.localLibraryItem = localLibraryItem
|
||||
self.serverConnectionConfigId = serverConnectionConfigId
|
||||
self.serverAddress = serverAddress
|
||||
self.isActiveSession = true
|
||||
}
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, userId, libraryItemId, episodeId, mediaType, mediaMetadata, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, mediaPlayer, deviceInfo, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId
|
||||
}
|
||||
|
||||
override init() {}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
userId = try values.decodeIfPresent(String.self, forKey: .userId)
|
||||
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
|
||||
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
|
||||
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||
mediaMetadata = try values.decodeIfPresent(Metadata.self, forKey: .mediaMetadata)
|
||||
if let chapterList = try values.decodeIfPresent([Chapter].self, forKey: .chapters) {
|
||||
chapters.append(objectsIn: chapterList)
|
||||
}
|
||||
displayTitle = try values.decodeIfPresent(String.self, forKey: .displayTitle)
|
||||
displayAuthor = try values.decodeIfPresent(String.self, forKey: .displayAuthor)
|
||||
coverPath = try values.decodeIfPresent(String.self, forKey: .coverPath)
|
||||
duration = try values.decode(Double.self, forKey: .duration)
|
||||
playMethod = try values.decode(Int.self, forKey: .playMethod)
|
||||
startedAt = try values.decodeIfPresent(Double.self, forKey: .startedAt)
|
||||
updatedAt = try values.decodeIfPresent(Double.self, forKey: .updatedAt)
|
||||
timeListening = try values.decode(Double.self, forKey: .timeListening)
|
||||
if let trackList = try values.decodeIfPresent([AudioTrack].self, forKey: .audioTracks) {
|
||||
audioTracks.append(objectsIn: trackList)
|
||||
}
|
||||
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||
libraryItem = try values.decodeIfPresent(LibraryItem.self, forKey: .libraryItem)
|
||||
localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem)
|
||||
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||
isActiveSession = true
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||
try container.encode(episodeId, forKey: .episodeId)
|
||||
try container.encode(mediaType, forKey: .mediaType)
|
||||
try container.encode(mediaMetadata, forKey: .mediaMetadata)
|
||||
try container.encode(Array(chapters), forKey: .chapters)
|
||||
try container.encode(displayTitle, forKey: .displayTitle)
|
||||
try container.encode(displayAuthor, forKey: .displayAuthor)
|
||||
try container.encode(coverPath, forKey: .coverPath)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
try container.encode(playMethod, forKey: .playMethod)
|
||||
try container.encode(mediaPlayer, forKey: .mediaPlayer)
|
||||
try container.encode(deviceInfo, forKey: .deviceInfo)
|
||||
try container.encode(startedAt, forKey: .startedAt)
|
||||
try container.encode(updatedAt, forKey: .updatedAt)
|
||||
try container.encode(timeListening, forKey: .timeListening)
|
||||
try container.encode(Array(audioTracks), forKey: .audioTracks)
|
||||
try container.encode(currentTime, forKey: .currentTime)
|
||||
try container.encode(libraryItem, forKey: .libraryItem)
|
||||
try container.encode(localLibraryItem, forKey: .localLibraryItem)
|
||||
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||
try container.encode(serverAddress, forKey: .serverAddress)
|
||||
try container.encode(isLocal, forKey: .isLocal)
|
||||
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
ios/App/Shared/models/PlayerSettings.swift
Normal file
30
ios/App/Shared/models/PlayerSettings.swift
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// PlayerSettings.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/18/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class PlayerSettings: Object {
|
||||
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
|
||||
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
|
||||
@Persisted var playbackRate: Float = 1.0
|
||||
|
||||
// Singleton pattern for Realm objects
|
||||
static func main() -> PlayerSettings {
|
||||
let realm = try! Realm()
|
||||
|
||||
if let settings = realm.objects(PlayerSettings.self).last {
|
||||
return settings
|
||||
}
|
||||
|
||||
let settings = PlayerSettings()
|
||||
try! realm.write {
|
||||
realm.add(settings)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
}
|
||||
15
ios/App/Shared/models/PlayerState.swift
Normal file
15
ios/App/Shared/models/PlayerState.swift
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
// PlayerState.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PlayerState: Codable {
|
||||
case IDLE
|
||||
case BUFFERING
|
||||
case READY
|
||||
case ENDED
|
||||
}
|
||||
|
|
@ -9,30 +9,28 @@ import Foundation
|
|||
import RealmSwift
|
||||
|
||||
class ServerConnectionConfig: Object {
|
||||
@Persisted(primaryKey: true) var id: String
|
||||
@Persisted(indexed: true) var index: Int
|
||||
@Persisted var name: String
|
||||
@Persisted var address: String
|
||||
@Persisted var userId: String
|
||||
@Persisted var username: String
|
||||
@Persisted var token: String
|
||||
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||
@Persisted(indexed: true) var index: Int = 1
|
||||
@Persisted var name: String = ""
|
||||
@Persisted var address: String = ""
|
||||
@Persisted var userId: String = ""
|
||||
@Persisted var username: String = ""
|
||||
@Persisted var token: String = ""
|
||||
}
|
||||
|
||||
class ServerConnectionConfigActiveIndex: Object {
|
||||
// This could overflow, but you really would have to try
|
||||
@Persisted var index: Int?
|
||||
|
||||
@Persisted(primaryKey: true) var index: Int?
|
||||
}
|
||||
|
||||
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
|
||||
return Database.realmQueue.sync {
|
||||
return [
|
||||
"id": config.id,
|
||||
"name": config.name,
|
||||
"index": config.index,
|
||||
"address": config.address,
|
||||
"userId": config.userId,
|
||||
"username": config.username,
|
||||
"token": config.token,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"id": config.id,
|
||||
"name": config.name,
|
||||
"index": config.index,
|
||||
"address": config.address,
|
||||
"userId": config.userId,
|
||||
"username": config.username,
|
||||
"token": config.token,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
97
ios/App/Shared/models/download/DownloadItem.swift
Normal file
97
ios/App/Shared/models/download/DownloadItem.swift
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//
|
||||
// DownloadItem.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class DownloadItem: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id: String?
|
||||
@Persisted(indexed: true) var libraryItemId: String?
|
||||
@Persisted var episodeId: String?
|
||||
@Persisted var userMediaProgress: MediaProgress?
|
||||
@Persisted var serverConnectionConfigId: String?
|
||||
@Persisted var serverAddress: String?
|
||||
@Persisted var serverUserId: String?
|
||||
@Persisted var mediaType: String?
|
||||
@Persisted var itemTitle: String?
|
||||
@Persisted var media: MediaType?
|
||||
@Persisted var downloadItemParts = List<DownloadItemPart>()
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, libraryItemId, episodeId, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try? values.decode(String.self, forKey: .id)
|
||||
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
|
||||
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
|
||||
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
|
||||
mediaType = try? values.decode(String.self, forKey: .mediaType)
|
||||
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||
if let parts = try? values.decode([DownloadItemPart].self, forKey: .downloadItemParts) {
|
||||
downloadItemParts.append(objectsIn: parts)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||
try container.encode(episodeId, forKey: .episodeId)
|
||||
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||
try container.encode(serverAddress, forKey: .serverAddress)
|
||||
try container.encode(serverUserId, forKey: .serverUserId)
|
||||
try container.encode(mediaType, forKey: .mediaType)
|
||||
try container.encode(itemTitle, forKey: .itemTitle)
|
||||
try container.encode(Array(downloadItemParts), forKey: .downloadItemParts)
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadItem {
|
||||
convenience init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) {
|
||||
self.init()
|
||||
|
||||
self.id = libraryItem.id
|
||||
self.libraryItemId = libraryItem.id
|
||||
self.userMediaProgress = libraryItem.userMediaProgress
|
||||
self.serverConnectionConfigId = server.id
|
||||
self.serverAddress = server.address
|
||||
self.serverUserId = server.userId
|
||||
self.mediaType = libraryItem.mediaType
|
||||
self.itemTitle = libraryItem.media?.metadata?.title
|
||||
self.media = libraryItem.media
|
||||
|
||||
if let episodeId = episodeId {
|
||||
self.id! += "-\(episodeId)"
|
||||
self.episodeId = episodeId
|
||||
}
|
||||
}
|
||||
|
||||
func isDoneDownloading() -> Bool {
|
||||
self.downloadItemParts.allSatisfy({ $0.completed })
|
||||
}
|
||||
|
||||
func didDownloadSuccessfully() -> Bool {
|
||||
self.downloadItemParts.allSatisfy({ $0.failed == false })
|
||||
}
|
||||
|
||||
func delete() {
|
||||
try! self.realm?.write {
|
||||
self.realm?.delete(self.downloadItemParts)
|
||||
self.realm?.delete(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
ios/App/Shared/models/download/DownloadItemPart.swift
Normal file
95
ios/App/Shared/models/download/DownloadItemPart.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//
|
||||
// DownloadItemPart.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class DownloadItemPart: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id = ""
|
||||
@Persisted var filename: String?
|
||||
@Persisted var itemTitle: String?
|
||||
@Persisted var serverPath: String?
|
||||
@Persisted var audioTrack: AudioTrack?
|
||||
@Persisted var episode: PodcastEpisode?
|
||||
@Persisted var completed: Bool = false
|
||||
@Persisted var moved: Bool = false
|
||||
@Persisted var failed: Bool = false
|
||||
@Persisted var uri: String?
|
||||
@Persisted var destinationUri: String?
|
||||
@Persisted var progress: Double = 0
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, filename, itemTitle, completed, moved, failed, progress
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
filename = try? values.decode(String.self, forKey: .filename)
|
||||
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||
completed = try values.decode(Bool.self, forKey: .completed)
|
||||
moved = try values.decode(Bool.self, forKey: .moved)
|
||||
failed = try values.decode(Bool.self, forKey: .failed)
|
||||
progress = try values.decode(Double.self, forKey: .progress)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(filename, forKey: .filename)
|
||||
try container.encode(itemTitle, forKey: .itemTitle)
|
||||
try container.encode(completed, forKey: .completed)
|
||||
try container.encode(moved, forKey: .moved)
|
||||
try container.encode(failed, forKey: .failed)
|
||||
try container.encode(progress, forKey: .progress)
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadItemPart {
|
||||
convenience init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) {
|
||||
self.init()
|
||||
|
||||
self.id = destination.toBase64()
|
||||
self.filename = filename
|
||||
self.itemTitle = itemTitle
|
||||
self.serverPath = serverPath
|
||||
self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
|
||||
self.episode = PodcastEpisode.detachCopy(of: episode)
|
||||
|
||||
let config = Store.serverConfig!
|
||||
var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)"
|
||||
if (serverPath.hasSuffix("/cover")) {
|
||||
downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||
}
|
||||
self.uri = downloadUrl
|
||||
self.destinationUri = destination
|
||||
}
|
||||
|
||||
var downloadURL: URL? {
|
||||
if let uri = self.uri {
|
||||
return URL(string: uri)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var destinationURL: URL? {
|
||||
if let destinationUri = self.destinationUri {
|
||||
return AbsDownloader.itemDownloadFolder(path: destinationUri)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func mimeType() -> String? {
|
||||
audioTrack?.mimeType ?? episode?.audioTrack?.mimeType
|
||||
}
|
||||
}
|
||||
73
ios/App/Shared/models/local/LocalFile.swift
Normal file
73
ios/App/Shared/models/local/LocalFile.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//
|
||||
// LocalFile.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class LocalFile: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||
@Persisted var filename: String?
|
||||
@Persisted var _contentUrl: String = ""
|
||||
@Persisted var mimeType: String?
|
||||
@Persisted var size: Int = 0
|
||||
|
||||
var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString }
|
||||
var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! }
|
||||
var basePath: String? { self.filename }
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, filename, contentUrl, mimeType, size, basePath
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
filename = try? values.decode(String.self, forKey: .filename)
|
||||
mimeType = try? values.decode(String.self, forKey: .mimeType)
|
||||
size = try values.decode(Int.self, forKey: .size)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(filename, forKey: .filename)
|
||||
try container.encode(contentUrl, forKey: .contentUrl)
|
||||
try container.encode(mimeType, forKey: .mimeType)
|
||||
try container.encode(size, forKey: .size)
|
||||
try container.encode(basePath, forKey: .basePath)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalFile {
|
||||
convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) {
|
||||
self.init()
|
||||
|
||||
self.id = "\(libraryItemId)_\(filename.toBase64())"
|
||||
self.filename = filename
|
||||
self.mimeType = mimeType
|
||||
self._contentUrl = localUrl
|
||||
self.size = fileSize
|
||||
}
|
||||
|
||||
var absolutePath: String {
|
||||
return AbsDownloader.itemDownloadFolder(path: self._contentUrl)?.absoluteString ?? ""
|
||||
}
|
||||
|
||||
func isAudioFile() -> Bool {
|
||||
switch self.mimeType {
|
||||
case "application/octet-stream",
|
||||
"video/mp4":
|
||||
return true
|
||||
default:
|
||||
return self.mimeType?.starts(with: "audio") ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
207
ios/App/Shared/models/local/LocalLibraryItem.swift
Normal file
207
ios/App/Shared/models/local/LocalLibraryItem.swift
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
//
|
||||
// LocalLibraryItem.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class LocalLibraryItem: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id: String = "local_\(UUID().uuidString)"
|
||||
@Persisted var basePath: String = ""
|
||||
@Persisted var _contentUrl: String?
|
||||
@Persisted var isInvalid: Bool = false
|
||||
@Persisted var mediaType: String = ""
|
||||
@Persisted var media: MediaType?
|
||||
@Persisted var localFiles = List<LocalFile>()
|
||||
@Persisted var _coverContentUrl: String?
|
||||
@Persisted var isLocal: Bool = true
|
||||
@Persisted var serverConnectionConfigId: String?
|
||||
@Persisted var serverAddress: String?
|
||||
@Persisted var serverUserId: String?
|
||||
@Persisted(indexed: true) var libraryItemId: String?
|
||||
|
||||
var contentUrl: String? {
|
||||
if let path = _contentUrl {
|
||||
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var contentDirectory: URL? {
|
||||
if let path = _contentUrl {
|
||||
return AbsDownloader.itemDownloadFolder(path: path)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var coverContentUrl: String? {
|
||||
if let path = self._coverContentUrl {
|
||||
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var isBook: Bool { self.mediaType == "book" }
|
||||
var isPodcast: Bool { self.mediaType == "podcast" }
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
basePath = try values.decode(String.self, forKey: .basePath)
|
||||
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
|
||||
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||
media = try? values.decode(MediaType.self, forKey: .media)
|
||||
if let files = try? values.decode([LocalFile].self, forKey: .localFiles) {
|
||||
localFiles.append(objectsIn: files)
|
||||
}
|
||||
isLocal = try values.decode(Bool.self, forKey: .isLocal)
|
||||
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
|
||||
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
|
||||
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
|
||||
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(basePath, forKey: .basePath)
|
||||
try container.encode(contentUrl, forKey: .contentUrl)
|
||||
try container.encode(isInvalid, forKey: .isInvalid)
|
||||
try container.encode(mediaType, forKey: .mediaType)
|
||||
try container.encode(media, forKey: .media)
|
||||
try container.encode(Array(localFiles), forKey: .localFiles)
|
||||
try container.encode(coverContentUrl, forKey: .coverContentUrl)
|
||||
try container.encode(isLocal, forKey: .isLocal)
|
||||
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||
try container.encode(serverAddress, forKey: .serverAddress)
|
||||
try container.encode(serverUserId, forKey: .serverUserId)
|
||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalLibraryItem {
|
||||
convenience init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) {
|
||||
self.init()
|
||||
|
||||
self._contentUrl = localUrl
|
||||
self.mediaType = item.mediaType
|
||||
self.localFiles.append(objectsIn: files)
|
||||
self._coverContentUrl = coverPath
|
||||
self.libraryItemId = item.id
|
||||
self.serverConnectionConfigId = server.id
|
||||
self.serverAddress = server.address
|
||||
self.serverUserId = server.userId
|
||||
|
||||
// Link the audio tracks and files
|
||||
linkLocalFiles(self.localFiles, fromMedia: item.media)
|
||||
}
|
||||
|
||||
func addFiles(_ files: [LocalFile], item: LibraryItem) throws {
|
||||
guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported }
|
||||
self.localFiles.append(objectsIn: files.filter({ $0.isAudioFile() }))
|
||||
linkLocalFiles(self.localFiles, fromMedia: item.media)
|
||||
}
|
||||
|
||||
private func linkLocalFiles(_ files: List<LocalFile>, fromMedia: MediaType?) {
|
||||
guard let fromMedia = MediaType.detachCopy(of: fromMedia) else { return }
|
||||
let fileMap = files.map { ($0.filename ?? "", $0.id) }
|
||||
let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last })
|
||||
if ( self.isBook ) {
|
||||
for i in fromMedia.tracks.indices {
|
||||
_ = fromMedia.tracks[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
|
||||
}
|
||||
} else if ( self.isPodcast ) {
|
||||
let episodes = List<PodcastEpisode>()
|
||||
for episode in fromMedia.episodes {
|
||||
// Filter out episodes not downloaded
|
||||
let episodeIsDownloaded = episode.audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) ?? false
|
||||
if episodeIsDownloaded {
|
||||
episodes.append(episode)
|
||||
}
|
||||
}
|
||||
fromMedia.episodes = episodes
|
||||
}
|
||||
self.media = fromMedia
|
||||
}
|
||||
|
||||
func getDuration() -> Double {
|
||||
var total = 0.0
|
||||
self.media?.tracks.enumerated().forEach { _, track in total += track.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
func getPodcastEpisode(episodeId: String?) -> PodcastEpisode? {
|
||||
guard self.isPodcast else { return nil }
|
||||
guard let episodes = self.media?.episodes else { return nil }
|
||||
return episodes.first(where: { $0.id == episodeId })
|
||||
}
|
||||
|
||||
func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession {
|
||||
let localEpisodeId = episode?.id
|
||||
let sessionId = "play_local_\(UUID().uuidString)"
|
||||
|
||||
// Get current progress from local media
|
||||
let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id
|
||||
let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId)
|
||||
|
||||
let mediaMetadata = Metadata.detachCopy(of: self.media?.metadata)
|
||||
let chapters = List<Chapter>()
|
||||
self.media?.chapters.forEach { chapter in chapters.append(Chapter.detachCopy(of: chapter)!) }
|
||||
let authorName = mediaMetadata?.authorDisplayName
|
||||
|
||||
let audioTracks = List<AudioTrack>()
|
||||
if let episode = episode, let track = episode.audioTrack {
|
||||
audioTracks.append(AudioTrack.detachCopy(of: track)!)
|
||||
} else if let tracks = self.media?.tracks {
|
||||
tracks.forEach { t in audioTracks.append(AudioTrack.detachCopy(of: t)!) }
|
||||
}
|
||||
|
||||
let dateNow = Date().timeIntervalSince1970 * 1000
|
||||
return PlaybackSession(
|
||||
id: sessionId,
|
||||
userId: self.serverUserId,
|
||||
libraryItemId: self.libraryItemId,
|
||||
episodeId: episode?.serverEpisodeId,
|
||||
mediaType: self.mediaType,
|
||||
mediaMetadata: mediaMetadata,
|
||||
chapters: chapters,
|
||||
displayTitle: mediaMetadata?.title,
|
||||
displayAuthor: authorName,
|
||||
coverPath: self.coverContentUrl,
|
||||
duration: self.getDuration(),
|
||||
playMethod: PlayMethod.local.rawValue,
|
||||
startedAt: dateNow,
|
||||
updatedAt: dateNow,
|
||||
timeListening: 0.0,
|
||||
audioTracks: audioTracks,
|
||||
currentTime: mediaProgress?.currentTime ?? 0.0,
|
||||
libraryItem: nil,
|
||||
localLibraryItem: self,
|
||||
serverConnectionConfigId: self.serverConnectionConfigId,
|
||||
serverAddress: self.serverAddress
|
||||
)
|
||||
}
|
||||
|
||||
func delete() {
|
||||
try! self.realm?.write {
|
||||
self.realm?.delete(self.localFiles)
|
||||
self.realm?.delete(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
ios/App/Shared/models/local/LocalMediaProgress.swift
Normal file
173
ios/App/Shared/models/local/LocalMediaProgress.swift
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//
|
||||
// LocalMediaProgress.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class LocalMediaProgress: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id: String = ""
|
||||
@Persisted(indexed: true) var localLibraryItemId: String = ""
|
||||
@Persisted(indexed: true) var localEpisodeId: String?
|
||||
@Persisted var duration: Double = 0
|
||||
@Persisted var progress: Double = 0
|
||||
@Persisted var currentTime: Double = 0
|
||||
@Persisted var isFinished: Bool = false
|
||||
@Persisted var lastUpdate: Double = 0
|
||||
@Persisted var startedAt: Double = 0
|
||||
@Persisted var finishedAt: Double?
|
||||
// For local lib items from server to support server sync
|
||||
@Persisted var serverConnectionConfigId: String?
|
||||
@Persisted var serverAddress: String?
|
||||
@Persisted var serverUserId: String?
|
||||
@Persisted(indexed: true) var libraryItemId: String?
|
||||
@Persisted(indexed: true) var episodeId: String?
|
||||
|
||||
var progressPercent: Int { Int(self.progress * 100) }
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
localLibraryItemId = try values.decode(String.self, forKey: .localLibraryItemId)
|
||||
localEpisodeId = try values.decodeIfPresent(String.self, forKey: .localEpisodeId)
|
||||
duration = try values.decode(Double.self, forKey: .duration)
|
||||
progress = try values.decode(Double.self, forKey: .progress)
|
||||
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
||||
lastUpdate = try values.decode(Double.self, forKey: .lastUpdate)
|
||||
startedAt = try values.decode(Double.self, forKey: .startedAt)
|
||||
finishedAt = try values.decodeIfPresent(Double.self, forKey: .finishedAt)
|
||||
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||
serverUserId = try values.decodeIfPresent(String.self, forKey: .serverUserId)
|
||||
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
|
||||
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(localLibraryItemId, forKey: .localLibraryItemId)
|
||||
try container.encode(localEpisodeId, forKey: .localEpisodeId)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
try container.encode(progress, forKey: .progress)
|
||||
try container.encode(currentTime, forKey: .currentTime)
|
||||
try container.encode(isFinished, forKey: .isFinished)
|
||||
try container.encode(lastUpdate, forKey: .lastUpdate)
|
||||
try container.encode(startedAt, forKey: .startedAt)
|
||||
try container.encode(finishedAt, forKey: .finishedAt)
|
||||
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
|
||||
try container.encode(serverAddress, forKey: .serverAddress)
|
||||
try container.encode(serverUserId, forKey: .serverUserId)
|
||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||
try container.encode(episodeId, forKey: .episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalMediaProgress {
|
||||
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) {
|
||||
self.init()
|
||||
|
||||
self.id = localLibraryItem.id
|
||||
self.localLibraryItemId = localLibraryItem.id
|
||||
self.libraryItemId = localLibraryItem.libraryItemId
|
||||
|
||||
self.serverAddress = localLibraryItem.serverAddress
|
||||
self.serverUserId = localLibraryItem.serverUserId
|
||||
self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId
|
||||
|
||||
self.duration = localLibraryItem.getDuration()
|
||||
self.progress = 0.0
|
||||
self.currentTime = 0.0
|
||||
self.isFinished = false
|
||||
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||
self.startedAt = 0
|
||||
self.finishedAt = nil
|
||||
|
||||
if let episode = episode {
|
||||
self.id += "-\(episode.id)"
|
||||
self.episodeId = episode.id
|
||||
self.duration = episode.duration ?? 0.0
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) {
|
||||
self.init(localLibraryItem: localLibraryItem, episode: episode)
|
||||
self.duration = progress.duration
|
||||
self.progress = progress.progress
|
||||
self.currentTime = progress.currentTime
|
||||
self.isFinished = progress.isFinished
|
||||
self.lastUpdate = progress.lastUpdate
|
||||
self.startedAt = progress.startedAt
|
||||
self.finishedAt = progress.finishedAt
|
||||
}
|
||||
|
||||
func updateIsFinished(_ finished: Bool) {
|
||||
try! Realm().write {
|
||||
if self.isFinished != finished {
|
||||
self.progress = finished ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
if self.startedAt == 0 && finished {
|
||||
self.startedAt = Date().timeIntervalSince1970 * 1000
|
||||
}
|
||||
|
||||
self.isFinished = finished
|
||||
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||
self.finishedAt = finished ? lastUpdate : nil
|
||||
}
|
||||
}
|
||||
|
||||
func updateFromPlaybackSession(_ playbackSession: PlaybackSession) {
|
||||
try! Realm().write {
|
||||
self.currentTime = playbackSession.currentTime
|
||||
self.progress = playbackSession.progress
|
||||
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||
self.isFinished = playbackSession.progress >= 100.0
|
||||
self.finishedAt = self.isFinished ? self.lastUpdate : nil
|
||||
}
|
||||
}
|
||||
|
||||
func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) {
|
||||
try! Realm().write {
|
||||
self.isFinished = serverMediaProgress.isFinished
|
||||
self.progress = serverMediaProgress.progress
|
||||
self.currentTime = serverMediaProgress.currentTime
|
||||
self.duration = serverMediaProgress.duration
|
||||
self.lastUpdate = serverMediaProgress.lastUpdate
|
||||
self.finishedAt = serverMediaProgress.finishedAt
|
||||
self.startedAt = serverMediaProgress.startedAt
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) -> LocalMediaProgress? {
|
||||
if let localMediaProgressId = localMediaProgressId {
|
||||
// Check if it existing in the database, if not, we need to create it
|
||||
if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) {
|
||||
return progress
|
||||
}
|
||||
}
|
||||
|
||||
if let localLibraryItemId = localLibraryItemId {
|
||||
guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil }
|
||||
let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId)
|
||||
return LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
ios/App/Shared/models/local/LocalPodcastEpisode.swift
Normal file
46
ios/App/Shared/models/local/LocalPodcastEpisode.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// LocalPodcastEpisode.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class LocalPodcastEpisode: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||
@Persisted var index: Int = 0
|
||||
@Persisted var episode: String?
|
||||
@Persisted var episodeType: String?
|
||||
@Persisted var title: String = "Unknown"
|
||||
@Persisted var subtitle: String?
|
||||
@Persisted var desc: String?
|
||||
@Persisted var audioFile: AudioFile?
|
||||
@Persisted var audioTrack: AudioTrack?
|
||||
@Persisted var duration: Double = 0
|
||||
@Persisted var size: Int = 0
|
||||
@Persisted(indexed: true) var serverEpisodeId: String?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
ios/App/Shared/models/server/AudioFile.swift
Normal file
38
ios/App/Shared/models/server/AudioFile.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// AudioFile.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class AudioFile: EmbeddedObject, Codable {
|
||||
@Persisted var index: Int?
|
||||
@Persisted var ino: String = ""
|
||||
@Persisted var metadata: FileMetadata?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case index, ino, metadata
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
index = try? values.decode(Int.self, forKey: .index)
|
||||
ino = try values.decode(String.self, forKey: .ino)
|
||||
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(index, forKey: .index)
|
||||
try container.encode(ino, forKey: .ino)
|
||||
try container.encode(metadata, forKey: .metadata)
|
||||
}
|
||||
}
|
||||
72
ios/App/Shared/models/server/AudioTrack.swift
Normal file
72
ios/App/Shared/models/server/AudioTrack.swift
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// AudioTrack.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class AudioTrack: EmbeddedObject, Codable {
|
||||
@Persisted var index: Int?
|
||||
@Persisted var startOffset: Double?
|
||||
@Persisted var duration: Double = 0
|
||||
@Persisted var title: String?
|
||||
@Persisted var contentUrl: String?
|
||||
@Persisted var mimeType: String = ""
|
||||
@Persisted var metadata: FileMetadata?
|
||||
@Persisted var localFileId: String?
|
||||
@Persisted var serverIndex: Int?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
index = try? values.decode(Int.self, forKey: .index)
|
||||
startOffset = try? values.decode(Double.self, forKey: .startOffset)
|
||||
duration = try values.decode(Double.self, forKey: .duration)
|
||||
title = try? values.decode(String.self, forKey: .title)
|
||||
contentUrl = try? values.decode(String.self, forKey: .contentUrl)
|
||||
mimeType = try values.decode(String.self, forKey: .mimeType)
|
||||
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
|
||||
localFileId = try! values.decodeIfPresent(String.self, forKey: .localFileId)
|
||||
serverIndex = try? values.decode(Int.self, forKey: .serverIndex)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(index, forKey: .index)
|
||||
try container.encode(startOffset, forKey: .startOffset)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
try container.encode(title, forKey: .title)
|
||||
try container.encode(contentUrl, forKey: .contentUrl)
|
||||
try container.encode(mimeType, forKey: .mimeType)
|
||||
try container.encode(metadata, forKey: .metadata)
|
||||
try container.encode(localFileId, forKey: .localFileId)
|
||||
try container.encode(serverIndex, forKey: .serverIndex)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioTrack {
|
||||
func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) -> Bool {
|
||||
if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] {
|
||||
self.localFileId = localFileId
|
||||
self.serverIndex = serverIndex
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getLocalFile() -> LocalFile? {
|
||||
guard let localFileId = self.localFileId else { return nil }
|
||||
return Database.shared.getLocalFile(localFileId: localFileId)
|
||||
}
|
||||
}
|
||||
38
ios/App/Shared/models/server/Author.swift
Normal file
38
ios/App/Shared/models/server/Author.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// Author.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class Author: EmbeddedObject, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var name: String = "Unknown"
|
||||
@Persisted var coverPath: String?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, name, coverPath
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
name = try values.decode(String.self, forKey: .name)
|
||||
coverPath = try? values.decode(String.self, forKey: .coverPath)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(coverPath, forKey: .coverPath)
|
||||
}
|
||||
}
|
||||
41
ios/App/Shared/models/server/Chapter.swift
Normal file
41
ios/App/Shared/models/server/Chapter.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// Chapter.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class Chapter: EmbeddedObject, Codable {
|
||||
@Persisted var id: Int = 0
|
||||
@Persisted var start: Double = 0
|
||||
@Persisted var end: Double = 0
|
||||
@Persisted var title: String?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, start, end, title
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(Int.self, forKey: .id)
|
||||
start = try values.decode(Double.self, forKey: .start)
|
||||
end = try values.decode(Double.self, forKey: .end)
|
||||
title = try? values.decode(String.self, forKey: .title)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(start, forKey: .start)
|
||||
try container.encode(end, forKey: .end)
|
||||
try container.encode(title, forKey: .title)
|
||||
}
|
||||
}
|
||||
41
ios/App/Shared/models/server/FileMetadata.swift
Normal file
41
ios/App/Shared/models/server/FileMetadata.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// FileMetadata.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class FileMetadata: EmbeddedObject, Codable {
|
||||
@Persisted var filename: String = ""
|
||||
@Persisted var ext: String = ""
|
||||
@Persisted var path: String = ""
|
||||
@Persisted var relPath: String = ""
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case filename, ext, path, relPath
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
filename = try values.decode(String.self, forKey: .filename)
|
||||
ext = try values.decode(String.self, forKey: .ext)
|
||||
path = try values.decode(String.self, forKey: .path)
|
||||
relPath = try values.decode(String.self, forKey: .relPath)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(filename, forKey: .filename)
|
||||
try container.encode(ext, forKey: .ext)
|
||||
try container.encode(path, forKey: .path)
|
||||
try container.encode(relPath, forKey: .relPath)
|
||||
}
|
||||
}
|
||||
35
ios/App/Shared/models/server/Folder.swift
Normal file
35
ios/App/Shared/models/server/Folder.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// Folder.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class Folder: EmbeddedObject, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var fullPath: String = ""
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, fullPath
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
fullPath = try values.decode(String.self, forKey: .fullPath)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(fullPath, forKey: .fullPath)
|
||||
}
|
||||
}
|
||||
46
ios/App/Shared/models/server/Library.swift
Normal file
46
ios/App/Shared/models/server/Library.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// Library.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class Library: EmbeddedObject, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var name: String = "Unknown"
|
||||
@Persisted var folders = List<Folder>()
|
||||
@Persisted var icon: String = ""
|
||||
@Persisted var mediaType: String = ""
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, name, folders, icon, mediaType
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
name = try values.decode(String.self, forKey: .name)
|
||||
if let folderList = try? values.decode([Folder].self, forKey: .folders) {
|
||||
folders.append(objectsIn: folderList)
|
||||
}
|
||||
icon = try values.decode(String.self, forKey: .icon)
|
||||
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(folders, forKey: .folders)
|
||||
try container.encode(icon, forKey: .icon)
|
||||
try container.encode(mediaType, forKey: .mediaType)
|
||||
}
|
||||
}
|
||||
35
ios/App/Shared/models/server/LibraryFile.swift
Normal file
35
ios/App/Shared/models/server/LibraryFile.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// LibraryFile.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class LibraryFile: EmbeddedObject, Codable {
|
||||
@Persisted var ino: String = ""
|
||||
@Persisted var metadata: FileMetadata?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case ino, metadata
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
ino = try values.decode(String.self, forKey: .ino)
|
||||
metadata = try values.decode(FileMetadata.self, forKey: .metadata)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(ino, forKey: .ino)
|
||||
try container.encode(metadata, forKey: .metadata)
|
||||
}
|
||||
}
|
||||
92
ios/App/Shared/models/server/LibraryItem.swift
Normal file
92
ios/App/Shared/models/server/LibraryItem.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// LibraryItem.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class LibraryItem: Object, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var ino: String = ""
|
||||
@Persisted var libraryId: String = ""
|
||||
@Persisted var folderId: String = ""
|
||||
@Persisted var path: String = ""
|
||||
@Persisted var relPath: String = ""
|
||||
@Persisted var isFile: Bool = true
|
||||
@Persisted var mtimeMs: Int = 0
|
||||
@Persisted var ctimeMs: Int = 0
|
||||
@Persisted var birthtimeMs: Int = 0
|
||||
@Persisted var addedAt: Int = 0
|
||||
@Persisted var updatedAt: Int = 0
|
||||
@Persisted var lastScan: Int?
|
||||
@Persisted var scanVersion: String?
|
||||
@Persisted var isMissing: Bool = false
|
||||
@Persisted var isInvalid: Bool = false
|
||||
@Persisted var mediaType: String = ""
|
||||
@Persisted var media: MediaType?
|
||||
@Persisted var libraryFiles = List<LibraryFile>()
|
||||
@Persisted var userMediaProgress: MediaProgress?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, ino, libraryId, folderId, path, relPath, isFile, mtimeMs, ctimeMs, birthtimeMs, addedAt, updatedAt, lastScan, scanVersion, isMissing, isInvalid, mediaType, media, libraryFiles, userMediaProgress
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
ino = try values.decode(String.self, forKey: .ino)
|
||||
libraryId = try values.decode(String.self, forKey: .libraryId)
|
||||
folderId = try values.decode(String.self, forKey: .folderId)
|
||||
path = try values.decode(String.self, forKey: .path)
|
||||
relPath = try values.decode(String.self, forKey: .relPath)
|
||||
isFile = try values.decode(Bool.self, forKey: .isFile)
|
||||
mtimeMs = try values.decode(Int.self, forKey: .mtimeMs)
|
||||
ctimeMs = try values.decode(Int.self, forKey: .ctimeMs)
|
||||
birthtimeMs = try values.decode(Int.self, forKey: .birthtimeMs)
|
||||
addedAt = try values.decode(Int.self, forKey: .addedAt)
|
||||
updatedAt = try values.decode(Int.self, forKey: .updatedAt)
|
||||
lastScan = try? values.decode(Int.self, forKey: .lastScan)
|
||||
scanVersion = try? values.decode(String.self, forKey: .scanVersion)
|
||||
isMissing = try values.decode(Bool.self, forKey: .isMissing)
|
||||
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
|
||||
mediaType = try values.decode(String.self, forKey: .mediaType)
|
||||
media = try? values.decode(MediaType.self, forKey: .media)
|
||||
if let files = try? values.decode([LibraryFile].self, forKey: .libraryFiles) {
|
||||
libraryFiles.append(objectsIn: files)
|
||||
}
|
||||
userMediaProgress = try? values.decode(MediaProgress.self, forKey: .userMediaProgress)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(ino, forKey: .ino)
|
||||
try container.encode(libraryId, forKey: .libraryId)
|
||||
try container.encode(folderId, forKey: .folderId)
|
||||
try container.encode(path, forKey: .path)
|
||||
try container.encode(relPath, forKey: .relPath)
|
||||
try container.encode(isFile, forKey: .isFile)
|
||||
try container.encode(mtimeMs, forKey: .mtimeMs)
|
||||
try container.encode(ctimeMs, forKey: .ctimeMs)
|
||||
try container.encode(birthtimeMs, forKey: .birthtimeMs)
|
||||
try container.encode(addedAt, forKey: .addedAt)
|
||||
try container.encode(updatedAt, forKey: .updatedAt)
|
||||
try container.encode(lastScan, forKey: .lastScan)
|
||||
try container.encode(scanVersion, forKey: .scanVersion)
|
||||
try container.encode(isMissing, forKey: .isMissing)
|
||||
try container.encode(isInvalid, forKey: .isInvalid)
|
||||
try container.encode(mediaType, forKey: .mediaType)
|
||||
try container.encode(media, forKey: .media)
|
||||
try container.encode(Array(libraryFiles), forKey: .libraryFiles)
|
||||
try container.encode(userMediaProgress, forKey: .userMediaProgress)
|
||||
}
|
||||
}
|
||||
58
ios/App/Shared/models/server/MediaProgress.swift
Normal file
58
ios/App/Shared/models/server/MediaProgress.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// MediaProgress.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class MediaProgress: EmbeddedObject, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var libraryItemId: String = ""
|
||||
@Persisted var episodeId: String?
|
||||
@Persisted var duration: Double = 0
|
||||
@Persisted var progress: Double = 0
|
||||
@Persisted var currentTime: Double = 0
|
||||
@Persisted var isFinished: Bool = false
|
||||
@Persisted var lastUpdate: Double = 0
|
||||
@Persisted var startedAt: Double = 0
|
||||
@Persisted var finishedAt: Double?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
|
||||
episodeId = try? values.decode(String.self, forKey: .episodeId)
|
||||
duration = try values.doubleOrStringDecoder(key: .duration)
|
||||
progress = try values.doubleOrStringDecoder(key: .progress)
|
||||
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
|
||||
isFinished = try values.decode(Bool.self, forKey: .isFinished)
|
||||
lastUpdate = try values.doubleOrStringDecoder(key: .lastUpdate)
|
||||
startedAt = try values.doubleOrStringDecoder(key: .startedAt)
|
||||
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||
try container.encode(episodeId, forKey: .episodeId)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
try container.encode(progress, forKey: .progress)
|
||||
try container.encode(currentTime, forKey: .currentTime)
|
||||
try container.encode(isFinished, forKey: .isFinished)
|
||||
try container.encode(lastUpdate, forKey: .lastUpdate)
|
||||
try container.encode(startedAt, forKey: .startedAt)
|
||||
try container.encode(finishedAt, forKey: .finishedAt)
|
||||
}
|
||||
}
|
||||
72
ios/App/Shared/models/server/MediaType.swift
Normal file
72
ios/App/Shared/models/server/MediaType.swift
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// MediaType.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class MediaType: EmbeddedObject, Codable {
|
||||
@Persisted var libraryItemId: String?
|
||||
@Persisted var metadata: Metadata?
|
||||
@Persisted var coverPath: String?
|
||||
@Persisted var tags = List<String>()
|
||||
@Persisted var audioFiles = List<AudioFile>()
|
||||
@Persisted var chapters = List<Chapter>()
|
||||
@Persisted var tracks = List<AudioTrack>()
|
||||
@Persisted var size: Int?
|
||||
@Persisted var duration: Double?
|
||||
@Persisted var episodes = List<PodcastEpisode>()
|
||||
@Persisted var autoDownloadEpisodes: Bool?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case libraryItemId, metadata, coverPath, tags, audioFiles, chapters, tracks, size, duration, episodes, autoDownloadEpisodes
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
|
||||
metadata = try? values.decode(Metadata.self, forKey: .metadata)
|
||||
coverPath = try? values.decode(String.self, forKey: .coverPath)
|
||||
if let tagList = try? values.decode([String].self, forKey: .tags) {
|
||||
tags.append(objectsIn: tagList)
|
||||
}
|
||||
if let fileList = try? values.decode([AudioFile].self, forKey: .audioFiles) {
|
||||
audioFiles.append(objectsIn: fileList)
|
||||
}
|
||||
if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) {
|
||||
chapters.append(objectsIn: chapterList)
|
||||
}
|
||||
if let trackList = try? values.decode([AudioTrack].self, forKey: .tracks) {
|
||||
tracks.append(objectsIn: trackList)
|
||||
}
|
||||
size = try? values.decode(Int.self, forKey: .size)
|
||||
duration = try? values.decode(Double.self, forKey: .duration)
|
||||
if let episodeList = try? values.decode([PodcastEpisode].self, forKey: .episodes) {
|
||||
episodes.append(objectsIn: episodeList)
|
||||
}
|
||||
autoDownloadEpisodes = try? values.decode(Bool.self, forKey: .autoDownloadEpisodes)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(libraryItemId, forKey: .libraryItemId)
|
||||
try container.encode(metadata, forKey: .metadata)
|
||||
try container.encode(coverPath, forKey: .coverPath)
|
||||
try container.encode(Array(tags), forKey: .tags)
|
||||
try container.encode(Array(audioFiles), forKey: .audioFiles)
|
||||
try container.encode(Array(chapters), forKey: .chapters)
|
||||
try container.encode(Array(tracks), forKey: .tracks)
|
||||
try container.encode(size, forKey: .size)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
try container.encode(Array(episodes), forKey: .episodes)
|
||||
try container.encode(autoDownloadEpisodes, forKey: .autoDownloadEpisodes)
|
||||
}
|
||||
}
|
||||
108
ios/App/Shared/models/server/Metadata.swift
Normal file
108
ios/App/Shared/models/server/Metadata.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
//
|
||||
// Metadata.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class Metadata: EmbeddedObject, Codable {
|
||||
@Persisted var title: String = "Unknown"
|
||||
@Persisted var subtitle: String?
|
||||
@Persisted var authors = List<Author>()
|
||||
@Persisted var narrators = List<String>()
|
||||
@Persisted var genres = List<String>()
|
||||
@Persisted var publishedYear: String?
|
||||
@Persisted var publishedDate: String?
|
||||
@Persisted var publisher: String?
|
||||
@Persisted var desc: String?
|
||||
@Persisted var isbn: String?
|
||||
@Persisted var asin: String?
|
||||
@Persisted var language: String?
|
||||
@Persisted var explicit: Bool = false
|
||||
@Persisted var authorName: String?
|
||||
@Persisted var authorNameLF: String?
|
||||
@Persisted var narratorName: String?
|
||||
@Persisted var seriesName: String?
|
||||
@Persisted var feedUrl: String?
|
||||
|
||||
var authorDisplayName: String { self.authorName ?? "Unknown" }
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case title,
|
||||
subtitle,
|
||||
authors,
|
||||
narrators,
|
||||
genres,
|
||||
publishedYear,
|
||||
publishedDate,
|
||||
publisher,
|
||||
desc = "description", // Fixes a collision with the base Swift object's field "description"
|
||||
isbn,
|
||||
asin,
|
||||
language,
|
||||
explicit,
|
||||
authorName,
|
||||
authorNameLF,
|
||||
narratorName,
|
||||
seriesName,
|
||||
feedUrl
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = try values.decode(String.self, forKey: .title)
|
||||
subtitle = try? values.decode(String.self, forKey: .subtitle)
|
||||
if let authorList = try? values.decode([Author].self, forKey: .authors) {
|
||||
authors.append(objectsIn: authorList)
|
||||
}
|
||||
if let narratorList = try? values.decode([String].self, forKey: .narrators) {
|
||||
narrators.append(objectsIn: narratorList)
|
||||
}
|
||||
if let genreList = try? values.decode([String].self, forKey: .genres) {
|
||||
genres.append(objectsIn: genreList)
|
||||
}
|
||||
publishedYear = try? values.decode(String.self, forKey: .publishedYear)
|
||||
publishedDate = try? values.decode(String.self, forKey: .publishedDate)
|
||||
publisher = try? values.decode(String.self, forKey: .publisher)
|
||||
desc = try? values.decode(String.self, forKey: .desc)
|
||||
isbn = try? values.decode(String.self, forKey: .isbn)
|
||||
asin = try? values.decode(String.self, forKey: .asin)
|
||||
language = try? values.decode(String.self, forKey: .language)
|
||||
explicit = try values.decode(Bool.self, forKey: .explicit)
|
||||
authorName = try? values.decode(String.self, forKey: .authorName)
|
||||
authorNameLF = try? values.decode(String.self, forKey: .authorNameLF)
|
||||
narratorName = try? values.decode(String.self, forKey: .narratorName)
|
||||
seriesName = try? values.decode(String.self, forKey: .seriesName)
|
||||
feedUrl = try? values.decode(String.self, forKey: .feedUrl)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(title, forKey: .title)
|
||||
try container.encode(subtitle, forKey: .subtitle)
|
||||
try container.encode(Array(authors), forKey: .authors)
|
||||
try container.encode(Array(narrators), forKey: .narrators)
|
||||
try container.encode(Array(genres), forKey: .genres)
|
||||
try container.encode(publishedYear, forKey: .publishedYear)
|
||||
try container.encode(publishedDate, forKey: .publishedDate)
|
||||
try container.encode(publisher, forKey: .publisher)
|
||||
try container.encode(desc, forKey: .desc)
|
||||
try container.encode(isbn, forKey: .isbn)
|
||||
try container.encode(asin, forKey: .asin)
|
||||
try container.encode(language, forKey: .language)
|
||||
try container.encode(explicit, forKey: .explicit)
|
||||
try container.encode(authorName, forKey: .authorName)
|
||||
try container.encode(authorNameLF, forKey: .authorNameLF)
|
||||
try container.encode(narratorName, forKey: .narratorName)
|
||||
try container.encode(seriesName, forKey: .seriesName)
|
||||
try container.encode(feedUrl, forKey: .feedUrl)
|
||||
}
|
||||
}
|
||||
72
ios/App/Shared/models/server/PodcastEpisode.swift
Normal file
72
ios/App/Shared/models/server/PodcastEpisode.swift
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// PodcastEpisode.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class PodcastEpisode: EmbeddedObject, Codable {
|
||||
@Persisted var id: String = ""
|
||||
@Persisted var index: Int?
|
||||
@Persisted var episode: String?
|
||||
@Persisted var episodeType: String?
|
||||
@Persisted var title: String = "Unknown"
|
||||
@Persisted var subtitle: String?
|
||||
@Persisted var desc: String?
|
||||
@Persisted var audioFile: AudioFile?
|
||||
@Persisted var audioTrack: AudioTrack?
|
||||
@Persisted var duration: Double?
|
||||
@Persisted var size: Int?
|
||||
var serverEpisodeId: String { self.id }
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id,
|
||||
index,
|
||||
episode,
|
||||
episodeType,
|
||||
title,
|
||||
subtitle,
|
||||
desc = "description", // Fixes a collision with the base Swift object's field "description"
|
||||
audioFile,
|
||||
audioTrack,
|
||||
duration,
|
||||
size,
|
||||
serverEpisodeId
|
||||
}
|
||||
|
||||
override init() {}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
index = try? values.decode(Int.self, forKey: .index)
|
||||
episode = try? values.decode(String.self, forKey: .episode)
|
||||
episodeType = try? values.decode(String.self, forKey: .episodeType)
|
||||
title = try values.decode(String.self, forKey: .title)
|
||||
subtitle = try? values.decode(String.self, forKey: .subtitle)
|
||||
desc = try? values.decode(String.self, forKey: .desc)
|
||||
audioFile = try? values.decode(AudioFile.self, forKey: .audioFile)
|
||||
audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack)
|
||||
duration = try? values.decode(Double.self, forKey: .duration)
|
||||
size = try? values.decode(Int.self, forKey: .size)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(index, forKey: .index)
|
||||
try container.encode(episode, forKey: .episode)
|
||||
try container.encode(episodeType, forKey: .episodeType)
|
||||
try container.encode(title, forKey: .title)
|
||||
try container.encode(subtitle, forKey: .subtitle)
|
||||
try container.encode(desc, forKey: .desc)
|
||||
try container.encode(audioFile, forKey: .audioFile)
|
||||
try container.encode(audioTrack, forKey: .audioTrack)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
try container.encode(size, forKey: .size)
|
||||
try container.encode(serverEpisodeId, forKey: .serverEpisodeId)
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ class AudioPlayer: NSObject {
|
|||
private var initialPlaybackRate: Float
|
||||
|
||||
private var audioPlayer: AVQueuePlayer
|
||||
private var playbackSession: PlaybackSession
|
||||
private var sessionId: String
|
||||
|
||||
private var queueObserver:NSKeyValueObservation?
|
||||
private var queueItemStatusObserver:NSKeyValueObservation?
|
||||
|
|
@ -41,12 +41,12 @@ class AudioPlayer: NSObject {
|
|||
private var allPlayerItems:[AVPlayerItem] = []
|
||||
|
||||
// MARK: - Constructor
|
||||
init(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
||||
init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) {
|
||||
self.playWhenReady = playWhenReady
|
||||
self.initialPlaybackRate = playbackRate
|
||||
self.audioPlayer = AVQueuePlayer()
|
||||
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
|
||||
self.playbackSession = playbackSession
|
||||
self.sessionId = sessionId
|
||||
self.status = -1
|
||||
self.rate = 0.0
|
||||
self.tmpRate = playbackRate
|
||||
|
|
@ -56,6 +56,8 @@ class AudioPlayer: NSObject {
|
|||
initAudioSession()
|
||||
setupRemoteTransportControls()
|
||||
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
|
||||
// Listen to player events
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
||||
|
|
@ -105,7 +107,12 @@ class AudioPlayer: NSObject {
|
|||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
|
||||
}
|
||||
|
||||
func isInitialized() -> Bool {
|
||||
return self.status != -1
|
||||
}
|
||||
|
||||
func getItemIndexForTime(time:Double) -> Int {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
for index in 0..<self.allPlayerItems.count {
|
||||
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
|
||||
let duration = playbackSession.audioTracks[index].duration
|
||||
|
|
@ -132,22 +139,27 @@ class AudioPlayer: NSObject {
|
|||
func setupQueueItemStatusObserver() {
|
||||
self.queueItemStatusObserver?.invalidate()
|
||||
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
if (playerItem.status == .readyToPlay) {
|
||||
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
||||
self.updateNowPlaying()
|
||||
|
||||
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
|
||||
let firstReady = self.status < 0
|
||||
if firstReady || self.playWhenReady {
|
||||
self.seek(playbackSession.currentTime, from: "queueItemStatusObserver")
|
||||
}
|
||||
|
||||
// Mark the player as ready
|
||||
self.status = 0
|
||||
|
||||
// Start the player, if requested
|
||||
if self.playWhenReady {
|
||||
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
|
||||
self.playWhenReady = false
|
||||
self.play()
|
||||
} else if (firstReady) { // Only seek on first readyToPlay
|
||||
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
|
||||
}
|
||||
} else if (playerItem.status == .failed) {
|
||||
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
||||
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
}
|
||||
})
|
||||
|
|
@ -155,6 +167,8 @@ class AudioPlayer: NSObject {
|
|||
|
||||
// MARK: - Methods
|
||||
public func play(allowSeekBack: Bool = false) {
|
||||
guard self.isInitialized() else { return }
|
||||
|
||||
if allowSeekBack {
|
||||
let diffrence = Date.timeIntervalSinceReferenceDate - lastPlayTime
|
||||
var time: Int?
|
||||
|
|
@ -190,6 +204,8 @@ class AudioPlayer: NSObject {
|
|||
}
|
||||
|
||||
public func pause() {
|
||||
guard self.isInitialized() else { return }
|
||||
|
||||
self.audioPlayer.pause()
|
||||
self.status = 0
|
||||
self.rate = 0.0
|
||||
|
|
@ -205,7 +221,9 @@ class AudioPlayer: NSObject {
|
|||
|
||||
NSLog("Seek to \(to) from \(from)")
|
||||
|
||||
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex]
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
|
||||
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
|
||||
let ctso = currentTrack.startOffset ?? 0.0
|
||||
let trackEnd = ctso + currentTrack.duration
|
||||
NSLog("Seek current track END = \(trackEnd)")
|
||||
|
|
@ -218,7 +236,9 @@ class AudioPlayer: NSObject {
|
|||
if (self.currentTrackIndex != indexOfSeek) {
|
||||
self.currentTrackIndex = indexOfSeek
|
||||
|
||||
self.playbackSession.currentTime = to
|
||||
playbackSession.update {
|
||||
playbackSession.currentTime = to
|
||||
}
|
||||
|
||||
self.playWhenReady = continuePlaying // Only playWhenReady if already playing
|
||||
self.status = -1
|
||||
|
|
@ -232,7 +252,7 @@ class AudioPlayer: NSObject {
|
|||
setupQueueItemStatusObserver()
|
||||
} else {
|
||||
NSLog("Seeking in current item \(to)")
|
||||
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
|
||||
let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
|
||||
let seekTime = to - currentTrackStartOffset
|
||||
|
||||
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in
|
||||
|
|
@ -262,23 +282,28 @@ class AudioPlayer: NSObject {
|
|||
}
|
||||
|
||||
public func getCurrentTime() -> Double {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
let currentTrackTime = self.audioPlayer.currentTime().seconds
|
||||
let audioTrack = playbackSession.audioTracks[currentTrackIndex]
|
||||
let startOffset = audioTrack.startOffset ?? 0.0
|
||||
return startOffset + currentTrackTime
|
||||
}
|
||||
|
||||
public func getPlayMethod() -> Int {
|
||||
return self.playbackSession.playMethod
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
return playbackSession.playMethod
|
||||
}
|
||||
public func getPlaybackSession() -> PlaybackSession {
|
||||
return self.playbackSession
|
||||
public func getPlaybackSessionId() -> String {
|
||||
return self.sessionId
|
||||
}
|
||||
public func getDuration() -> Double {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
return playbackSession.duration
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
|
||||
// The only reason this is separate is because the filename needs to be encoded
|
||||
let filename = track.metadata?.filename ?? ""
|
||||
|
|
@ -286,6 +311,17 @@ class AudioPlayer: NSObject {
|
|||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
let url = URL(string: urlstr)!
|
||||
return AVURLAsset(url: url)
|
||||
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
|
||||
guard let localFile = track.getLocalFile() else {
|
||||
// Worst case we can stream the file
|
||||
NSLog("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
|
||||
let filename = track.metadata?.filename ?? ""
|
||||
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
let url = URL(string: urlstr)!
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
return AVURLAsset(url: localFile.contentPath)
|
||||
} else { // HLS Transcode
|
||||
let headers: [String: String] = [
|
||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class PlayerHandler {
|
||||
private static var player: AudioPlayer?
|
||||
private static var session: PlaybackSession?
|
||||
private static var timer: Timer?
|
||||
private static var lastSyncTime:Double = 0.0
|
||||
private static var playingTimer: Timer?
|
||||
private static var pausedTimer: Timer?
|
||||
private static var lastSyncTime: Double = 0.0
|
||||
|
||||
public static var sleepTimerChapterStopTime: Int? = nil
|
||||
private static var _remainingSleepTime: Int? = nil
|
||||
|
|
@ -34,7 +35,6 @@ class PlayerHandler {
|
|||
}
|
||||
}
|
||||
private static var listeningTimePassedSinceLastSync: Double = 0.0
|
||||
private static var lastSyncReport: PlaybackReport?
|
||||
|
||||
public static var paused: Bool {
|
||||
get {
|
||||
|
|
@ -49,33 +49,80 @@ class PlayerHandler {
|
|||
self.player?.pause()
|
||||
} else {
|
||||
self.player?.play()
|
||||
self.pausedTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
|
||||
public static func startTickTimer() {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
NSLog("Starting the tick timer")
|
||||
playingTimer?.invalidate()
|
||||
pausedTimer?.invalidate()
|
||||
playingTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func stopTickTimer() {
|
||||
NSLog("Stopping the tick timer")
|
||||
playingTimer?.invalidate()
|
||||
pausedTimer?.invalidate()
|
||||
playingTimer = nil
|
||||
}
|
||||
|
||||
private static func startPausedTimer() {
|
||||
guard self.paused else { return }
|
||||
self.pausedTimer?.invalidate()
|
||||
self.pausedTimer = Timer.scheduledTimer(timeInterval: 30, target: self, selector: #selector(syncServerProgressDuringPause), userInfo: nil, repeats: true)
|
||||
}
|
||||
|
||||
private static func cleanupOldSessions(currentSessionId: String?) {
|
||||
let realm = try! Realm()
|
||||
let oldSessions = realm.objects(PlaybackSession.self) .where({ $0.isActiveSession == true })
|
||||
try! realm.write {
|
||||
for s in oldSessions {
|
||||
if s.id != currentSessionId {
|
||||
s.isActiveSession = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) {
|
||||
guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return }
|
||||
|
||||
// Clean up the existing player
|
||||
if player != nil {
|
||||
player?.destroy()
|
||||
player = nil
|
||||
}
|
||||
|
||||
// Cleanup old sessions
|
||||
cleanupOldSessions(currentSessionId: sessionId)
|
||||
|
||||
// Set now playing info
|
||||
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
|
||||
|
||||
self.session = session
|
||||
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
// Create the audio player
|
||||
player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
|
||||
DispatchQueue.runOnMainQueue {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
startTickTimer()
|
||||
startPausedTimer()
|
||||
}
|
||||
|
||||
public static func stopPlayback() {
|
||||
// Pause playback first, so we can sync our current progress
|
||||
player?.pause()
|
||||
|
||||
// Stop updating progress before we destory the player, so we don't receive bad data
|
||||
stopTickTimer()
|
||||
|
||||
player?.destroy()
|
||||
player = nil
|
||||
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
cleanupOldSessions(currentSessionId: nil)
|
||||
|
||||
NowPlayingInfo.shared.reset()
|
||||
}
|
||||
|
|
@ -83,14 +130,19 @@ class PlayerHandler {
|
|||
public static func getCurrentTime() -> Double? {
|
||||
self.player?.getCurrentTime()
|
||||
}
|
||||
|
||||
public static func setPlaybackSpeed(speed: Float) {
|
||||
self.player?.setPlaybackRate(speed)
|
||||
}
|
||||
|
||||
public static func getPlayMethod() -> Int? {
|
||||
self.player?.getPlayMethod()
|
||||
}
|
||||
|
||||
public static func getPlaybackSession() -> PlaybackSession? {
|
||||
self.player?.getPlaybackSession()
|
||||
guard let player = player else { return nil }
|
||||
guard let session = Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) else { return nil }
|
||||
return session
|
||||
}
|
||||
|
||||
public static func seekForward(amount: Double) {
|
||||
|
|
@ -101,6 +153,7 @@ class PlayerHandler {
|
|||
let destinationTime = player.getCurrentTime() + amount
|
||||
player.seek(destinationTime, from: "handler")
|
||||
}
|
||||
|
||||
public static func seekBackward(amount: Double) {
|
||||
guard let player = player else {
|
||||
return
|
||||
|
|
@ -109,19 +162,24 @@ class PlayerHandler {
|
|||
let destinationTime = player.getCurrentTime() - amount
|
||||
player.seek(destinationTime, from: "handler")
|
||||
}
|
||||
|
||||
public static func seek(amount: Double) {
|
||||
player?.seek(amount, from: "handler")
|
||||
}
|
||||
public static func getMetdata() -> [String: Any] {
|
||||
|
||||
public static func getMetdata() -> [String: Any]? {
|
||||
guard let player = player else { return nil }
|
||||
guard player.isInitialized() else { return nil }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
syncProgress()
|
||||
syncPlayerProgress()
|
||||
}
|
||||
|
||||
return [
|
||||
"duration": player?.getDuration() ?? 0,
|
||||
"currentTime": player?.getCurrentTime() ?? 0,
|
||||
"duration": player.getDuration(),
|
||||
"currentTime": player.getCurrentTime(),
|
||||
"playerState": !paused,
|
||||
"currentRate": player?.rate ?? 0,
|
||||
"currentRate": player.rate,
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -148,18 +206,19 @@ class PlayerHandler {
|
|||
}
|
||||
|
||||
if listeningTimePassedSinceLastSync >= 5 {
|
||||
syncProgress()
|
||||
syncPlayerProgress()
|
||||
}
|
||||
}
|
||||
public static func syncProgress() {
|
||||
if session == nil { return }
|
||||
|
||||
public static func syncPlayerProgress() {
|
||||
guard let player = player else { return }
|
||||
guard player.isInitialized() else { return }
|
||||
guard let session = getPlaybackSession() else { return }
|
||||
|
||||
NSLog("Syncing player progress")
|
||||
|
||||
// Get current time
|
||||
let playerCurrentTime = player.getCurrentTime()
|
||||
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
|
||||
// No need to syncProgress
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent multiple sync requests
|
||||
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
|
||||
|
|
@ -168,16 +227,24 @@ class PlayerHandler {
|
|||
return
|
||||
}
|
||||
|
||||
// Prevent a sync if we got junk data from the player (occurs when exiting out of memory
|
||||
guard !playerCurrentTime.isNaN else { return }
|
||||
|
||||
lastSyncTime = Date().timeIntervalSince1970 // seconds
|
||||
|
||||
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
|
||||
|
||||
session!.currentTime = playerCurrentTime
|
||||
session.update {
|
||||
session.currentTime = playerCurrentTime
|
||||
session.timeListening += listeningTimePassedSinceLastSync
|
||||
session.updatedAt = Date().timeIntervalSince1970 * 1000
|
||||
}
|
||||
listeningTimePassedSinceLastSync = 0
|
||||
lastSyncReport = report
|
||||
|
||||
// TODO: check if online
|
||||
NSLog("sending playback report")
|
||||
ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id)
|
||||
// Persist items in the database and sync to the server
|
||||
if session.isLocal { PlayerProgress.syncFromPlayer() }
|
||||
Task { await PlayerProgress.syncToServer() }
|
||||
}
|
||||
|
||||
@objc public static func syncServerProgressDuringPause() {
|
||||
Task { await PlayerProgress.syncFromServer() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
ios/App/Shared/player/PlayerProgress.swift
Normal file
119
ios/App/Shared/player/PlayerProgress.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
//
|
||||
// PlayerProgressSync.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/19/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import RealmSwift
|
||||
|
||||
class PlayerProgress {
|
||||
|
||||
private init() {}
|
||||
|
||||
public static func syncFromPlayer() {
|
||||
updateLocalMediaProgressFromLocalSession()
|
||||
}
|
||||
|
||||
public static func syncToServer() async {
|
||||
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer")
|
||||
updateAllServerSessionFromLocalSession()
|
||||
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||
}
|
||||
|
||||
public static func syncFromServer() async {
|
||||
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer")
|
||||
await updateLocalSessionFromServerMediaProgress()
|
||||
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||
}
|
||||
|
||||
private static func updateLocalMediaProgressFromLocalSession() {
|
||||
guard let session = PlayerHandler.getPlaybackSession() else { return }
|
||||
guard session.isLocal else { return }
|
||||
|
||||
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId)
|
||||
guard let localMediaProgress = localMediaProgress else {
|
||||
// Local media progress should have been created
|
||||
// If we're here, it means a library id is invalid
|
||||
return
|
||||
}
|
||||
|
||||
localMediaProgress.updateFromPlaybackSession(session)
|
||||
Database.shared.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
NSLog("Local progress saved to the database")
|
||||
|
||||
// Send the local progress back to front-end
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||
}
|
||||
|
||||
private static func updateAllServerSessionFromLocalSession() {
|
||||
let sessions = try! Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id })
|
||||
for session in sessions {
|
||||
let session = session.freeze()
|
||||
Task { await updateServerSessionFromLocalSession(session) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func updateServerSessionFromLocalSession(_ session: PlaybackSession) async {
|
||||
NSLog("Sending sessionId(\(session.id)) to server")
|
||||
|
||||
var success = false
|
||||
if session.isLocal {
|
||||
success = await ApiClient.reportLocalPlaybackProgress(session)
|
||||
} else {
|
||||
let playbackReport = PlaybackReport(currentTime: session.currentTime, duration: session.duration, timeListened: session.timeListening)
|
||||
success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id)
|
||||
}
|
||||
|
||||
// Remove old sessions after they synced with the server
|
||||
if success && !session.isActiveSession {
|
||||
NSLog("Deleting sessionId(\(session.id)) as is no longer active")
|
||||
session.thaw()?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private static func updateLocalSessionFromServerMediaProgress() async {
|
||||
NSLog("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
|
||||
guard let session = try! await Realm().objects(PlaybackSession.self).last(where: { $0.isActiveSession == true })?.freeze() else {
|
||||
NSLog("updateLocalSessionFromServerMediaProgress: Failed to get session")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the current progress
|
||||
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
|
||||
guard let progress = progress else {
|
||||
NSLog("updateLocalSessionFromServerMediaProgress: No progress object")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which session is newer
|
||||
let serverLastUpdate = progress.lastUpdate
|
||||
guard let localLastUpdate = session.updatedAt else {
|
||||
NSLog("updateLocalSessionFromServerMediaProgress: No local session updatedAt")
|
||||
return
|
||||
}
|
||||
let serverCurrentTime = progress.currentTime
|
||||
let localCurrentTime = session.currentTime
|
||||
|
||||
let serverIsNewerThanLocal = serverLastUpdate > localLastUpdate
|
||||
let currentTimeIsDifferent = serverCurrentTime != localCurrentTime
|
||||
|
||||
// Update the session, if needed
|
||||
if serverIsNewerThanLocal && currentTimeIsDifferent {
|
||||
NSLog("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
|
||||
guard let session = session.thaw() else { return }
|
||||
session.update {
|
||||
session.currentTime = serverCurrentTime
|
||||
session.updatedAt = serverLastUpdate
|
||||
}
|
||||
NSLog("updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
|
||||
PlayerHandler.seek(amount: session.currentTime)
|
||||
} else {
|
||||
NSLog("updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -37,7 +37,37 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
}
|
||||
public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) {
|
||||
|
||||
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
NSLog("Server config not set")
|
||||
return
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||
]
|
||||
|
||||
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in
|
||||
switch response.result {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
NSLog("api request to \(endpoint) failed")
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func postResource<T:Encodable>(endpoint: String, parameters: T) async -> Bool {
|
||||
return await withCheckedContinuation { continuation in
|
||||
postResource(endpoint: endpoint, parameters: parameters) { success in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
NSLog("Server config not set")
|
||||
callback?(false)
|
||||
|
|
@ -50,7 +80,7 @@ class ApiClient {
|
|||
|
||||
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
|
||||
switch response.result {
|
||||
case .success(let _):
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
NSLog("api request to \(endpoint) failed")
|
||||
|
|
@ -60,6 +90,38 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
NSLog("Server config not set")
|
||||
callback?(false)
|
||||
return
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||
]
|
||||
|
||||
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
|
||||
switch response.result {
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
NSLog("api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self) async -> T? {
|
||||
return await withCheckedContinuation { continuation in
|
||||
getResource(endpoint: endpoint, decodable: decodable) { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
NSLog("Server config not set")
|
||||
|
|
@ -96,7 +158,7 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
ApiClient.postResource(endpoint: endpoint, parameters: [
|
||||
let parameters: [String: Any] = [
|
||||
"forceDirectPlay": !forceTranscode ? "1" : "",
|
||||
"forceTranscode": forceTranscode ? "1" : "",
|
||||
"mediaPlayer": "AVPlayer",
|
||||
|
|
@ -105,7 +167,8 @@ class ApiClient {
|
|||
"model": modelCode,
|
||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
]
|
||||
], decodable: PlaybackSession.self) { obj in
|
||||
]
|
||||
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
|
||||
var session = obj
|
||||
|
||||
session.serverConnectionConfigId = Store.serverConfig!.id
|
||||
|
|
@ -114,9 +177,55 @@ class ApiClient {
|
|||
callback(session)
|
||||
}
|
||||
}
|
||||
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) {
|
||||
try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil)
|
||||
|
||||
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
|
||||
return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report)
|
||||
}
|
||||
|
||||
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
||||
return await postResource(endpoint: "api/session/local", parameters: session)
|
||||
}
|
||||
|
||||
public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) {
|
||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.map { $0.freeze() }
|
||||
|
||||
if ( !localMediaProgressList.isEmpty ) {
|
||||
let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList)
|
||||
NSLog("Sending sync local progress request with \(localMediaProgressList.count) progress items")
|
||||
postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in
|
||||
let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count)
|
||||
NSLog("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))")
|
||||
|
||||
if let updates = response.localProgressUpdates {
|
||||
for update in updates {
|
||||
Database.shared.saveLocalMediaProgress(update)
|
||||
}
|
||||
}
|
||||
|
||||
callback(resultsPayload)
|
||||
}
|
||||
} else {
|
||||
NSLog("No local media progress to sync")
|
||||
callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0))
|
||||
}
|
||||
}
|
||||
|
||||
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
|
||||
NSLog("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
|
||||
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||
patchResource(endpoint: endpoint, parameters: payload) { success in
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
|
||||
NSLog("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
|
||||
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
|
||||
}
|
||||
|
||||
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
||||
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
||||
if episodeId != nil {
|
||||
|
|
@ -128,3 +237,35 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalMediaProgressSyncPayload: Codable {
|
||||
var localMediaProgress: [LocalMediaProgress]
|
||||
}
|
||||
|
||||
struct MediaProgressSyncResponsePayload: Decodable {
|
||||
var numServerProgressUpdates: Int?
|
||||
var localProgressUpdates: [LocalMediaProgress]?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case numServerProgressUpdates, localProgressUpdates
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
numServerProgressUpdates = try? values.intOrStringDecoder(key: .numServerProgressUpdates)
|
||||
localProgressUpdates = try? values.decode([LocalMediaProgress].self, forKey: .localProgressUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalMediaProgressSyncResultsPayload: Codable {
|
||||
var numLocalMediaProgressForServer: Int?
|
||||
var numServerProgressUpdates: Int?
|
||||
var numLocalProgressUpdates: Int?
|
||||
}
|
||||
|
||||
struct Connectivity {
|
||||
static private let sharedInstance = NetworkReachabilityManager()!
|
||||
static var isConnectedToInternet:Bool {
|
||||
return self.sharedInstance.isReachable
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
ios/App/Shared/util/DaoExtensions.swift
Normal file
45
ios/App/Shared/util/DaoExtensions.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// DaoExtensions.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 8/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
|
||||
extension Object {
|
||||
func save() {
|
||||
let realm = try! Realm()
|
||||
try! realm.write {
|
||||
realm.add(self, update: .modified)
|
||||
}
|
||||
}
|
||||
|
||||
func update(handler: () -> Void) {
|
||||
try! self.realm?.write {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EmbeddedObject {
|
||||
// Required to disassociate from Realm when copying into local objects
|
||||
static func detachCopy<T:Codable>(of object: T?) -> T? {
|
||||
guard let object = object else { return nil }
|
||||
let json = try! JSONEncoder().encode(object)
|
||||
return try! JSONDecoder().decode(T.self, from: json)
|
||||
}
|
||||
}
|
||||
|
||||
protocol Deletable {
|
||||
func delete()
|
||||
}
|
||||
|
||||
extension Deletable where Self: Object {
|
||||
func delete() {
|
||||
try! self.realm?.write {
|
||||
self.realm?.delete(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,143 +9,184 @@ import Foundation
|
|||
import RealmSwift
|
||||
|
||||
class Database {
|
||||
// All DB releated actions must be executed on "realm-queue"
|
||||
public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue")
|
||||
public static var shared = {
|
||||
realmQueue.sync {
|
||||
return Database()
|
||||
}
|
||||
return Database()
|
||||
}()
|
||||
|
||||
private var instance: Realm
|
||||
private init() {
|
||||
self.instance = try! Realm(queue: Database.realmQueue)
|
||||
}
|
||||
private init() {}
|
||||
|
||||
public func setServerConnectionConfig(config: ServerConnectionConfig) {
|
||||
var refrence: ThreadSafeReference<ServerConnectionConfig>?
|
||||
if config.realm != nil {
|
||||
refrence = ThreadSafeReference(to: config)
|
||||
}
|
||||
let config = config
|
||||
let realm = try! Realm()
|
||||
let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
|
||||
|
||||
Database.realmQueue.sync {
|
||||
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
|
||||
if config.index == 0 {
|
||||
let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last
|
||||
|
||||
if config.index == 0 {
|
||||
let lastConfig: ServerConnectionConfig? = instance.objects(ServerConnectionConfig.self).last
|
||||
|
||||
if lastConfig != nil {
|
||||
config.index = lastConfig!.index + 1
|
||||
} else {
|
||||
config.index = 1
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try instance.write {
|
||||
if existing != nil {
|
||||
instance.delete(existing!)
|
||||
}
|
||||
if refrence == nil {
|
||||
instance.add(config)
|
||||
} else {
|
||||
guard let resolved = instance.resolve(refrence!) else {
|
||||
throw "unable to resolve refrence"
|
||||
}
|
||||
|
||||
instance.add(resolved);
|
||||
}
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("failed to save server config")
|
||||
debugPrint(exception)
|
||||
}
|
||||
|
||||
setLastActiveConfigIndex(index: config.index)
|
||||
}
|
||||
}
|
||||
public func deleteServerConnectionConfig(id: String) {
|
||||
Database.realmQueue.sync {
|
||||
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
|
||||
|
||||
do {
|
||||
try instance.write {
|
||||
if config != nil {
|
||||
instance.delete(config!)
|
||||
}
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("failed to delete server config")
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
|
||||
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
|
||||
|
||||
Database.realmQueue.sync {
|
||||
let configs = instance.objects(ServerConnectionConfig.self)
|
||||
refrences = configs.map { config in
|
||||
return ThreadSafeReference(to: config)
|
||||
if lastConfig != nil {
|
||||
config.index = lastConfig!.index + 1
|
||||
} else {
|
||||
config.index = 1
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let realm = try Realm()
|
||||
|
||||
return refrences.map { refrence in
|
||||
return realm.resolve(refrence)!
|
||||
try realm.write {
|
||||
if existing != nil {
|
||||
realm.delete(existing!)
|
||||
}
|
||||
realm.add(config)
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("error while readling configs")
|
||||
NSLog("failed to save server config")
|
||||
debugPrint(exception)
|
||||
return []
|
||||
}
|
||||
|
||||
setLastActiveConfigIndex(index: config.index)
|
||||
}
|
||||
|
||||
public func deleteServerConnectionConfig(id: String) {
|
||||
let realm = try! Realm()
|
||||
let config = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
|
||||
|
||||
do {
|
||||
try realm.write {
|
||||
if config != nil {
|
||||
realm.delete(config!)
|
||||
}
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("failed to delete server config")
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
|
||||
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
|
||||
let realm = try! Realm()
|
||||
return Array(realm.objects(ServerConnectionConfig.self))
|
||||
}
|
||||
|
||||
public func setLastActiveConfigIndexToNil() {
|
||||
Database.realmQueue.sync {
|
||||
setLastActiveConfigIndex(index: nil)
|
||||
}
|
||||
setLastActiveConfigIndex(index: nil)
|
||||
}
|
||||
public func setLastActiveConfigIndex(index: Int?) {
|
||||
let existing = instance.objects(ServerConnectionConfigActiveIndex.self)
|
||||
let obj = ServerConnectionConfigActiveIndex()
|
||||
obj.index = index
|
||||
|
||||
|
||||
private func setLastActiveConfigIndex(index: Int?) {
|
||||
let realm = try! Realm()
|
||||
do {
|
||||
try instance.write {
|
||||
instance.delete(existing)
|
||||
instance.add(obj)
|
||||
try realm.write {
|
||||
let existing = realm.objects(ServerConnectionConfigActiveIndex.self).last
|
||||
|
||||
if ( existing?.index != index ) {
|
||||
if let existing = existing {
|
||||
realm.delete(existing)
|
||||
}
|
||||
|
||||
let activeConfig = ServerConnectionConfigActiveIndex()
|
||||
activeConfig.index = index
|
||||
realm.add(activeConfig)
|
||||
}
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("failed to save server config active index")
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
|
||||
public func getLastActiveConfigIndex() -> Int? {
|
||||
return Database.realmQueue.sync {
|
||||
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
||||
}
|
||||
let realm = try! Realm()
|
||||
return realm.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
||||
}
|
||||
|
||||
public func setDeviceSettings(deviceSettings: DeviceSettings) {
|
||||
Database.realmQueue.sync {
|
||||
let existing = instance.objects(DeviceSettings.self)
|
||||
let realm = try! Realm()
|
||||
let existing = realm.objects(DeviceSettings.self)
|
||||
|
||||
do {
|
||||
try instance.write {
|
||||
instance.delete(existing)
|
||||
instance.add(deviceSettings)
|
||||
}
|
||||
} catch(let exception) {
|
||||
NSLog("failed to save device settings")
|
||||
debugPrint(exception)
|
||||
do {
|
||||
try realm.write {
|
||||
realm.delete(existing)
|
||||
realm.add(deviceSettings)
|
||||
}
|
||||
} catch {
|
||||
NSLog("failed to save device settings")
|
||||
}
|
||||
}
|
||||
|
||||
public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] {
|
||||
let realm = try! Realm()
|
||||
return Array(realm.objects(LocalLibraryItem.self))
|
||||
}
|
||||
|
||||
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
|
||||
let realm = try! Realm()
|
||||
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId })
|
||||
}
|
||||
|
||||
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
|
||||
let realm = try! Realm()
|
||||
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId)
|
||||
}
|
||||
|
||||
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||
let realm = try! Realm()
|
||||
try! realm.write { realm.add(localLibraryItem, update: .modified) }
|
||||
}
|
||||
|
||||
public func getLocalFile(localFileId: String) -> LocalFile? {
|
||||
let realm = try! Realm()
|
||||
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId)
|
||||
}
|
||||
|
||||
public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
|
||||
let realm = try! Realm()
|
||||
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId)
|
||||
}
|
||||
|
||||
public func getDownloadItem(libraryItemId: String) -> DownloadItem? {
|
||||
let realm = try! Realm()
|
||||
return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first
|
||||
}
|
||||
|
||||
public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? {
|
||||
let realm = try! Realm()
|
||||
return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first
|
||||
}
|
||||
|
||||
public func saveDownloadItem(_ downloadItem: DownloadItem) {
|
||||
let realm = try! Realm()
|
||||
return try! realm.write { realm.add(downloadItem, update: .modified) }
|
||||
}
|
||||
|
||||
public func getDeviceSettings() -> DeviceSettings {
|
||||
return Database.realmQueue.sync {
|
||||
return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
|
||||
let realm = try! Realm()
|
||||
return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
|
||||
}
|
||||
|
||||
public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
|
||||
let realm = try! Realm()
|
||||
return Array(realm.objects(LocalMediaProgress.self))
|
||||
}
|
||||
|
||||
public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) {
|
||||
let realm = try! Realm()
|
||||
try! realm.write { realm.add(mediaProgress, update: .modified) }
|
||||
}
|
||||
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||
public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? {
|
||||
let realm = try! Realm()
|
||||
return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
|
||||
}
|
||||
|
||||
public func removeLocalMediaProgress(localMediaProgressId: String) {
|
||||
let realm = try! Realm()
|
||||
try! realm.write {
|
||||
let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
|
||||
realm.delete(progress!)
|
||||
}
|
||||
}
|
||||
|
||||
public func getPlaybackSession(id: String) -> PlaybackSession? {
|
||||
let realm = try! Realm()
|
||||
return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,18 +6,66 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RealmSwift
|
||||
import Capacitor
|
||||
import CoreMedia
|
||||
|
||||
extension String: Error {}
|
||||
|
||||
typealias Dictionaryable = Encodable
|
||||
|
||||
extension Encodable {
|
||||
func asDictionary() throws -> [String: Any] {
|
||||
let data = try JSONEncoder().encode(self)
|
||||
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
|
||||
throw NSError()
|
||||
func asDictionary() throws -> [String: Any] {
|
||||
let data = try JSONEncoder().encode(self)
|
||||
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
|
||||
throw NSError()
|
||||
}
|
||||
return dictionary
|
||||
}
|
||||
return dictionary
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Iterator.Element: Encodable {
|
||||
func asDictionaryArray() throws -> [[String: Any]] {
|
||||
return try self.enumerated().map() {
|
||||
i, element -> [String: Any] in try element.asDictionary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KeyedDecodingContainer {
|
||||
func doubleOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Double {
|
||||
do {
|
||||
return try decode(Double.self, forKey: key)
|
||||
} catch {
|
||||
let stringValue = try decode(String.self, forKey: key)
|
||||
return Double(stringValue) ?? 0.0
|
||||
}
|
||||
}
|
||||
|
||||
func intOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Int {
|
||||
do {
|
||||
return try decode(Int.self, forKey: key)
|
||||
} catch {
|
||||
let stringValue = try decode(String.self, forKey: key)
|
||||
return Int(stringValue) ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CAPPluginCall {
|
||||
func getJson<T: Decodable>(_ key: String, type: T.Type) -> T? {
|
||||
guard let value = getObject(key) else { return nil }
|
||||
do {
|
||||
let json = try JSONSerialization.data(withJSONObject: value)
|
||||
return try JSONDecoder().decode(type, from: json)
|
||||
} catch {
|
||||
NSLog("Failed to get json for \(key)")
|
||||
debugPrint(error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DispatchQueue {
|
||||
static func runOnMainQueue(callback: @escaping (() -> Void)) {
|
||||
if Thread.isMainThread {
|
||||
|
|
@ -29,3 +77,26 @@ extension DispatchQueue {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
var attributes: [FileAttributeKey : Any]? {
|
||||
do {
|
||||
return try FileManager.default.attributesOfItem(atPath: path)
|
||||
} catch let error as NSError {
|
||||
print("FileAttribute error: \(error)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var fileSize: Int64 {
|
||||
return attributes?[.size] as? Int64 ?? Int64(0)
|
||||
}
|
||||
|
||||
var fileSizeString: String {
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file)
|
||||
}
|
||||
|
||||
var creationDate: Date? {
|
||||
return attributes?[.creationDate] as? Date
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ struct NowPlayingMetadata {
|
|||
var title: String
|
||||
var author: String?
|
||||
var series: String?
|
||||
var coverUrl: URL? {
|
||||
guard let config = Store.serverConfig else { return nil }
|
||||
guard let url = URL(string: "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)") else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
class NowPlayingInfo {
|
||||
|
|
@ -30,18 +35,27 @@ class NowPlayingInfo {
|
|||
public func setSessionMetadata(metadata: NowPlayingMetadata) {
|
||||
setMetadata(artwork: nil, metadata: metadata)
|
||||
|
||||
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
|
||||
return
|
||||
}
|
||||
ApiClient.getData(from: url) { [self] image in
|
||||
guard let downloadedImage = image else {
|
||||
return
|
||||
let isLocalItem = metadata.itemId.starts(with: "local_")
|
||||
if isLocalItem {
|
||||
guard let artworkUrl = metadata.artworkUrl else { return }
|
||||
let coverImage = UIImage(contentsOfFile: artworkUrl)
|
||||
guard let coverImage = coverImage else { return }
|
||||
let artwork = MPMediaItemArtwork(boundsSize: coverImage.size) { _ -> UIImage in
|
||||
return coverImage
|
||||
}
|
||||
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
|
||||
return downloadedImage
|
||||
})
|
||||
|
||||
self.setMetadata(artwork: artwork, metadata: metadata)
|
||||
} else {
|
||||
guard let url = metadata.coverUrl else { return }
|
||||
ApiClient.getData(from: url) { [self] image in
|
||||
guard let downloadedImage = image else {
|
||||
return
|
||||
}
|
||||
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
|
||||
return downloadedImage
|
||||
})
|
||||
|
||||
self.setMetadata(artwork: artwork, metadata: metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func update(duration: Double, currentTime: Double, rate: Float) {
|
||||
|
|
@ -52,6 +66,7 @@ class NowPlayingInfo {
|
|||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
nowPlayingInfo = [:]
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
|
|
@ -76,6 +91,7 @@ class NowPlayingInfo {
|
|||
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
|
||||
}
|
||||
|
||||
private func shouldFetchCover(id: String) -> Bool {
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ enum PlayerEvents: String {
|
|||
case sleepSet = "com.audiobookshelf.app.player.sleep.set"
|
||||
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
|
||||
case failed = "com.audiobookshelf.app.player.failed"
|
||||
case localProgress = "com.audiobookshelf.app.player.localProgress"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import Foundation
|
|||
import RealmSwift
|
||||
|
||||
class Store {
|
||||
@ThreadSafe private static var _serverConfig: ServerConnectionConfig?
|
||||
private static var _serverConfig: ServerConnectionConfig?
|
||||
public static var serverConfig: ServerConnectionConfig? {
|
||||
get {
|
||||
return _serverConfig
|
||||
|
|
@ -21,9 +21,8 @@ class Store {
|
|||
Database.shared.setLastActiveConfigIndexToNil()
|
||||
}
|
||||
|
||||
Database.realmQueue.sync {
|
||||
_serverConfig = updated
|
||||
}
|
||||
// Make safe for accessing on all threads
|
||||
_serverConfig = updated?.freeze()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -279,6 +279,9 @@ export default {
|
|||
|
||||
this.loadSavedSettings()
|
||||
this.hasMounted = true
|
||||
|
||||
console.log('[default] fully initialized')
|
||||
this.$eventBus.$emit('abs-ui-ready')
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -32982,4 +32982,4 @@
|
|||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,7 +311,6 @@ export default {
|
|||
return this.ebookFile && this.ebookFormat !== 'pdf'
|
||||
},
|
||||
showDownload() {
|
||||
if (this.isIos) return false
|
||||
if (this.isPodcast) return false
|
||||
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<span class="material-icons" @click="showItemDialog">more_vert</span>
|
||||
</div>
|
||||
|
||||
<p class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
|
||||
<p v-if="!isIos" class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
|
||||
|
||||
<p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p>
|
||||
|
||||
|
|
@ -22,11 +22,11 @@
|
|||
<div v-if="!isPodcast" class="w-full">
|
||||
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
|
||||
|
||||
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos">
|
||||
<transition-group type="transition" :name="!drag ? 'dragtrack' : null">
|
||||
<template v-for="track in audioTracksCopy">
|
||||
<div :key="track.localFileId" class="flex items-center my-1 item">
|
||||
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
|
||||
<div v-if="!isIos" class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
|
||||
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
||||
</div>
|
||||
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
<p class="text-xs">{{ track.mimeType }}</p>
|
||||
<p class="text-sm">{{ $elapsedPretty(track.duration) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
|
||||
<div v-if="!isIos" class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
|
||||
<span class="material-icons" @click="showTrackDialog(track)">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -138,6 +138,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
basePath() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.basePath : null
|
||||
},
|
||||
|
|
@ -194,24 +197,14 @@ export default {
|
|||
}
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
text: 'Scan',
|
||||
value: 'scan'
|
||||
},
|
||||
{
|
||||
text: 'Force Re-Scan',
|
||||
value: 'rescan'
|
||||
},
|
||||
{
|
||||
text: 'Remove',
|
||||
value: 'remove'
|
||||
},
|
||||
{
|
||||
text: 'Remove & Delete Files',
|
||||
value: 'delete'
|
||||
}
|
||||
]
|
||||
var options = []
|
||||
if ( !this.isIos ) {
|
||||
options.push({ text: 'Scan', value: 'scan'})
|
||||
options.push({ text: 'Force Re-Scan', value: 'rescan'})
|
||||
options.push({ text: 'Remove', value: 'remove'})
|
||||
}
|
||||
options.push({ text: 'Remove & Delete Files', value: 'delete'})
|
||||
return options
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -329,13 +322,13 @@ export default {
|
|||
async deleteItem() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Warning! This will delete the folder "${this.basePath}" and all contents. Are you sure?`
|
||||
message: `Warning! This will delete "${this.media.metadata.title}" and all associated local files. Are you sure?`
|
||||
})
|
||||
if (value) {
|
||||
var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||
if (res && res.success) {
|
||||
this.$toast.success('Deleted Successfully')
|
||||
this.$router.replace(`/localMedia/folders/${this.folderId}`)
|
||||
this.$router.replace(this.isIos ? '/bookshelf' : `/localMedia/folders/${this.folderId}`)
|
||||
} else this.$toast.error('Failed to delete')
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue