@naxodev/opencode-music-player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,568 @@
1
+ /** OpenCode session facade plus host-local artwork presentation. */
2
+ import {
3
+ baselineCapabilities,
4
+ createReconnectingMusicSessionClient,
5
+ type ArtworkIdentity as SessionArtworkIdentity,
6
+ type ArtworkResult as SessionArtworkResult,
7
+ type CommandResult,
8
+ type MusicSessionConnectionLifecycle,
9
+ type ProviderStatus,
10
+ type ReconnectingMusicSessionClient,
11
+ type RevisionedState,
12
+ } from "@naxodev/music-core"
13
+ import { resolveArtworkDetails } from "./artwork.ts"
14
+ import type {
15
+ Artwork,
16
+ ArtworkCompletionEvent,
17
+ ArtworkIdentity,
18
+ ArtworkPresentationListener,
19
+ SessionMedia,
20
+ SessionMediaEvent,
21
+ SessionMediaLifecycleEvent,
22
+ PlayerState,
23
+ } from "./types.ts"
24
+
25
+ type ArtworkCacheEntry = {
26
+ value: Artwork | null
27
+ duration_ms: number
28
+ resolved: boolean
29
+ pending: boolean
30
+ attempts: number
31
+ retry_at: number
32
+ owner: PresentationHost | null
33
+ interests: Map<PresentationHost, ArtworkIdentity>
34
+ }
35
+ const MAX_ARTWORK_ENTRIES = 32
36
+ const artworkCache = new Map<string, ArtworkCacheEntry>()
37
+ const artworkJobs = new Map<string, ArtworkCacheEntry>()
38
+ type DeferredArtworkInterest = {
39
+ legacyKey: string
40
+ target: { title: string; artist: string; album: string; duration_ms: number }
41
+ native: () => Promise<string | null>
42
+ resolver: ArtworkResolver
43
+ host: PresentationHost
44
+ identity: ArtworkIdentity
45
+ now: () => number
46
+ }
47
+ type DeferredArtwork = {
48
+ interests: Map<PresentationHost, DeferredArtworkInterest>
49
+ }
50
+ // Deferred admissions are keyed by work, not host. This shares one eventual
51
+ // native/catalog request between views and bounds retained deferred work.
52
+ const waitingArtwork = new Map<string, DeferredArtwork>()
53
+ type ArtworkResolver = typeof resolveArtworkDetails
54
+
55
+ /** Public-contract-only seam for the reconnecting session adapter. */
56
+ export type SessionClientFactory = () => Promise<ReconnectingMusicSessionClient>
57
+ export type SessionSystemMediaOverrides = {
58
+ readonly createClient?: SessionClientFactory
59
+ readonly resolveArtworkDetails?: ArtworkResolver
60
+ readonly now?: () => number
61
+ }
62
+
63
+ let sessionClientSequence = 0
64
+ const createOpenCodeSessionClient: SessionClientFactory = () =>
65
+ createReconnectingMusicSessionClient({
66
+ clientId: `opencode-music-player-${++sessionClientSequence}`,
67
+ hostKind: "opencode",
68
+ capabilities: [...baselineCapabilities],
69
+ })
70
+
71
+ type PresentationHost = {
72
+ publish: (event: ArtworkCompletionEvent) => void
73
+ isActive: () => boolean
74
+ }
75
+
76
+ export type { CommandResult }
77
+
78
+ export function artworkIdentityKey(identity: ArtworkIdentity): string {
79
+ return JSON.stringify([
80
+ identity.uid,
81
+ identity.title,
82
+ identity.artist,
83
+ identity.album,
84
+ identity.duration_ms,
85
+ ])
86
+ }
87
+
88
+ /** Cache covers by recording metadata; volatile provider IDs remain valid. */
89
+ export function artworkCacheKey(identity: ArtworkIdentity): string {
90
+ return JSON.stringify([
91
+ identity.title,
92
+ identity.artist,
93
+ identity.album,
94
+ identity.duration_ms,
95
+ ])
96
+ }
97
+
98
+ function identityFromTrack(track: {
99
+ id: string
100
+ name: string
101
+ artists: string
102
+ album: string
103
+ duration_ms: number
104
+ }): ArtworkIdentity {
105
+ return {
106
+ uid: track.id,
107
+ title: track.name,
108
+ artist: track.artists,
109
+ album: track.album,
110
+ duration_ms: track.duration_ms,
111
+ }
112
+ }
113
+
114
+ function removeWaitingInterest(host: PresentationHost) {
115
+ for (const [key, deferred] of waitingArtwork) {
116
+ deferred.interests.delete(host)
117
+ if (deferred.interests.size === 0) waitingArtwork.delete(key)
118
+ }
119
+ }
120
+
121
+ function admitDeferredArtwork() {
122
+ const next = waitingArtwork.entries().next().value as
123
+ [string, DeferredArtwork] | undefined
124
+ if (!next) return
125
+ const [key, deferred] = next
126
+ waitingArtwork.delete(key)
127
+ for (const host of deferred.interests.keys()) {
128
+ if (!host.isActive()) deferred.interests.delete(host)
129
+ }
130
+ const leader = deferred.interests.values().next().value as
131
+ DeferredArtworkInterest | undefined
132
+ if (!leader) {
133
+ admitDeferredArtwork()
134
+ return
135
+ }
136
+ artworkForTrack(
137
+ key,
138
+ leader.legacyKey,
139
+ leader.target,
140
+ leader.native,
141
+ leader.resolver,
142
+ leader.host,
143
+ leader.identity,
144
+ leader.now,
145
+ )
146
+ const admitted = artworkJobs.get(key)
147
+ if (!admitted) return
148
+ for (const interest of deferred.interests.values()) {
149
+ if (interest.host.isActive())
150
+ admitted.interests.set(interest.host, interest.identity)
151
+ }
152
+ }
153
+
154
+ function releaseArtworkSlot(key: string, entry?: ArtworkCacheEntry) {
155
+ artworkJobs.delete(key)
156
+ if (entry) {
157
+ artworkCache.set(key, entry)
158
+ if (artworkCache.size > MAX_ARTWORK_ENTRIES) {
159
+ const oldest = artworkCache.keys().next().value
160
+ if (oldest) artworkCache.delete(oldest)
161
+ }
162
+ }
163
+ admitDeferredArtwork()
164
+ }
165
+
166
+ function settleArtworkEntry(key: string, entry: ArtworkCacheEntry) {
167
+ releaseArtworkSlot(key, entry)
168
+ }
169
+
170
+ function publishArtworkCompletion(
171
+ entry: ArtworkCacheEntry,
172
+ artwork: Artwork | null,
173
+ ) {
174
+ for (const [host, identity] of entry.interests) {
175
+ host.publish({
176
+ type: "artwork-completion",
177
+ identity,
178
+ artwork,
179
+ duration_ms: entry.duration_ms,
180
+ })
181
+ }
182
+ entry.interests.clear()
183
+ }
184
+
185
+ function removeArtworkInterests(host: PresentationHost) {
186
+ removeWaitingInterest(host)
187
+ for (const [key, entry] of artworkCache) {
188
+ entry.interests.delete(host)
189
+ if (entry.owner !== host || entry.pending) continue
190
+ if (entry.value === null) artworkCache.delete(key)
191
+ else entry.owner = null
192
+ }
193
+ for (const [key, entry] of artworkJobs) {
194
+ entry.interests.delete(host)
195
+ if (entry.owner !== host) continue
196
+ entry.pending = false
197
+ entry.owner = null
198
+ if (artworkCache.get(key) === entry && entry.value === null)
199
+ artworkCache.delete(key)
200
+ // Disposal/cancellation frees a slot too. A deferred current identity
201
+ // must be admitted even when its predecessor never resolves.
202
+ releaseArtworkSlot(key)
203
+ }
204
+ }
205
+
206
+ function artworkForTrack(
207
+ key: string,
208
+ legacyKey: string,
209
+ target: { title: string; artist: string; album: string; duration_ms: number },
210
+ native: () => Promise<string | null>,
211
+ resolver: ArtworkResolver,
212
+ host: PresentationHost,
213
+ identity: ArtworkIdentity,
214
+ now: () => number,
215
+ ): { artwork: Artwork | null; duration_ms: number; loading: boolean } {
216
+ let entry = artworkCache.get(key) ?? artworkJobs.get(key)
217
+ if (!entry) {
218
+ if (artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
219
+ removeWaitingInterest(host)
220
+ let deferred = waitingArtwork.get(key)
221
+ if (!deferred && waitingArtwork.size < MAX_ARTWORK_ENTRIES) {
222
+ deferred = { interests: new Map() }
223
+ waitingArtwork.set(key, deferred)
224
+ }
225
+ if (deferred && deferred.interests.size < MAX_ARTWORK_ENTRIES)
226
+ deferred.interests.set(host, {
227
+ legacyKey,
228
+ target,
229
+ native,
230
+ resolver,
231
+ host,
232
+ identity,
233
+ now,
234
+ })
235
+ // Overflow is deliberately stable: no additional host/job is retained
236
+ // and the unchanged track renders as a settled no-artwork result rather
237
+ // than a permanent loading indicator in the no-poll session model.
238
+ return {
239
+ artwork: null,
240
+ duration_ms: target.duration_ms,
241
+ loading: !!deferred && deferred.interests.has(host),
242
+ }
243
+ }
244
+ entry = {
245
+ value: null,
246
+ duration_ms: target.duration_ms,
247
+ resolved: false,
248
+ pending: false,
249
+ attempts: 0,
250
+ retry_at: 0,
251
+ owner: null,
252
+ interests: new Map(),
253
+ }
254
+ }
255
+ // A state change that reached admission supersedes any older deferred
256
+ // identity owned by this adapter generation.
257
+ removeWaitingInterest(host)
258
+ if (entry.pending && entry.owner && !entry.owner.isActive()) {
259
+ entry.pending = false
260
+ entry.owner = null
261
+ artworkJobs.delete(key)
262
+ }
263
+ if (
264
+ !entry.pending &&
265
+ entry.attempts < 3 &&
266
+ now() >= entry.retry_at &&
267
+ (!entry.resolved || entry.value === null)
268
+ ) {
269
+ entry.pending = true
270
+ entry.attempts++
271
+ artworkJobs.set(key, entry)
272
+ const activeEntry = entry
273
+ const activeHost = host
274
+ activeEntry.owner = activeHost
275
+ void (async () => {
276
+ let data: string | null = null
277
+ try {
278
+ data = await native()
279
+ } catch {
280
+ // Session artwork failure is transient; host catalog fallback remains.
281
+ }
282
+ if (!activeHost.isActive() || activeEntry.owner !== activeHost)
283
+ return { artwork: null, duration_ms: activeEntry.duration_ms }
284
+ return resolver(key, target, data, legacyKey)
285
+ })().then(
286
+ (resolution) => {
287
+ if (!activeHost.isActive() || activeEntry.owner !== activeHost) return
288
+ activeEntry.value = resolution.artwork
289
+ activeEntry.duration_ms = resolution.duration_ms
290
+ activeEntry.resolved = true
291
+ activeEntry.pending = false
292
+ if (!resolution.artwork)
293
+ activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
294
+ settleArtworkEntry(key, activeEntry)
295
+ publishArtworkCompletion(activeEntry, resolution.artwork)
296
+ },
297
+ () => {
298
+ if (!activeHost.isActive() || activeEntry.owner !== activeHost) return
299
+ activeEntry.value = null
300
+ activeEntry.resolved = true
301
+ activeEntry.pending = false
302
+ activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
303
+ settleArtworkEntry(key, activeEntry)
304
+ publishArtworkCompletion(activeEntry, null)
305
+ },
306
+ )
307
+ }
308
+ if (entry.pending) entry.interests.set(host, identity)
309
+ return {
310
+ artwork: entry.value,
311
+ duration_ms: entry.duration_ms,
312
+ loading: entry.pending,
313
+ }
314
+ }
315
+
316
+ /**
317
+ * OpenCode projection over one reconnecting core client. It owns no provider
318
+ * probing, native process execution, polling, playback clock, or command queue.
319
+ */
320
+ export function createSessionSystemMedia(
321
+ overrides: SessionSystemMediaOverrides = {},
322
+ ): SessionMedia {
323
+ const factory = overrides.createClient ?? createOpenCodeSessionClient
324
+ const resolver = overrides.resolveArtworkDetails ?? resolveArtworkDetails
325
+ const now = overrides.now ?? Date.now
326
+ const listeners = new Set<(event: SessionMediaEvent) => void>()
327
+ const presentationListeners = new Set<ArtworkPresentationListener>()
328
+ let disposed = false
329
+ let client: ReconnectingMusicSessionClient | undefined
330
+ let installed = false
331
+ let latest: RevisionedState | undefined
332
+ let latestStatus: ProviderStatus | undefined
333
+ let latestConnection: MusicSessionConnectionLifecycle | undefined
334
+ let acquisitionError: string | undefined
335
+ let publishedLifecycle: string | undefined
336
+ let unsubscribers: Array<() => void> = []
337
+ let clientReleased = false
338
+ let disposal: Promise<void> | undefined
339
+
340
+ const releaseClient = (next: ReconnectingMusicSessionClient) => {
341
+ if (clientReleased) return Promise.resolve()
342
+ clientReleased = true
343
+ return Promise.resolve(next.dispose()).catch(() => {})
344
+ }
345
+ const emit = (event: SessionMediaEvent) => {
346
+ if (disposed) return
347
+ for (const listener of [...listeners]) {
348
+ try {
349
+ listener(event)
350
+ } catch {
351
+ // One host observer cannot block another.
352
+ }
353
+ }
354
+ }
355
+ const host: PresentationHost = {
356
+ publish(event) {
357
+ if (disposed) return
358
+ for (const listener of [...presentationListeners]) {
359
+ try {
360
+ listener(event)
361
+ } catch {
362
+ // One presentation observer cannot block another.
363
+ }
364
+ }
365
+ },
366
+ isActive: () => !disposed,
367
+ }
368
+ const lifecycle = (): SessionMediaLifecycleEvent => {
369
+ if (
370
+ latestConnection?.type === "reconnecting" ||
371
+ latestConnection?.type === "terminal"
372
+ )
373
+ return {
374
+ type: "lifecycle",
375
+ message: latestConnection.error.message,
376
+ source: "connection",
377
+ }
378
+ if (latestConnection?.type === "disposed")
379
+ return {
380
+ type: "lifecycle",
381
+ message: "music session is disposed",
382
+ source: "connection",
383
+ }
384
+ if (acquisitionError)
385
+ return {
386
+ type: "lifecycle",
387
+ message: acquisitionError,
388
+ source: "acquisition",
389
+ }
390
+ return {
391
+ type: "lifecycle",
392
+ message:
393
+ latestStatus && latestStatus.kind !== "ready"
394
+ ? latestStatus.message
395
+ : null,
396
+ source: "provider",
397
+ }
398
+ }
399
+ const publishLifecycle = () => {
400
+ const event = lifecycle()
401
+ const key = `${event.source}:${event.message}`
402
+ if (publishedLifecycle === key) return
403
+ publishedLifecycle = key
404
+ emit(event)
405
+ }
406
+ const project = (state: RevisionedState | undefined): PlayerState | null => {
407
+ if (!state) return null
408
+ if (!state.state.track) return state.state as PlayerState
409
+ const track = state.state.track
410
+ const identity = identityFromTrack(track)
411
+ const artworkState = artworkForTrack(
412
+ artworkCacheKey(identity),
413
+ artworkIdentityKey(identity),
414
+ {
415
+ title: track.name,
416
+ artist: track.artists,
417
+ album: track.album,
418
+ duration_ms: track.duration_ms,
419
+ },
420
+ async () => {
421
+ const active = await clientPromise
422
+ if (disposed || active !== client) return null
423
+ const result: SessionArtworkResult = await active.artwork({
424
+ id: track.id,
425
+ name: track.name,
426
+ artists: track.artists,
427
+ album: track.album,
428
+ duration_ms: track.duration_ms,
429
+ } satisfies SessionArtworkIdentity)
430
+ return !disposed && active === client && result.type === "available"
431
+ ? result.base64
432
+ : null
433
+ },
434
+ resolver,
435
+ host,
436
+ identity,
437
+ now,
438
+ )
439
+ return {
440
+ ...state.state,
441
+ track: {
442
+ ...track,
443
+ duration_ms:
444
+ track.duration_ms > 0 ? track.duration_ms : artworkState.duration_ms,
445
+ artwork: artworkState.artwork,
446
+ artwork_loading: artworkState.loading,
447
+ },
448
+ }
449
+ }
450
+ const install = (next: ReconnectingMusicSessionClient) => {
451
+ if (installed) return
452
+ installed = true
453
+ client = next
454
+ latest = next.state
455
+ latestStatus = next.status
456
+ latestConnection = next.connection
457
+ unsubscribers = [
458
+ next.subscribeState((state) => {
459
+ if (disposed) return
460
+ latest = state
461
+ emit({ type: "snapshot", state: project(state)! })
462
+ }),
463
+ next.subscribeStatus((status) => {
464
+ if (disposed) return
465
+ latestStatus = status
466
+ publishLifecycle()
467
+ }),
468
+ next.subscribeConnection((connection) => {
469
+ if (disposed) return
470
+ latestConnection = connection
471
+ publishLifecycle()
472
+ }),
473
+ ]
474
+ }
475
+ const clientPromise = Promise.resolve()
476
+ .then(factory)
477
+ .then((next) => {
478
+ if (disposed) {
479
+ void releaseClient(next)
480
+ return next
481
+ }
482
+ install(next)
483
+ return next
484
+ })
485
+ void clientPromise.catch((error) => {
486
+ if (disposed) return
487
+ acquisitionError = error instanceof Error ? error.message : String(error)
488
+ publishLifecycle()
489
+ })
490
+ const activeClient = async () => {
491
+ const next = await clientPromise
492
+ if (disposed || next !== client)
493
+ throw new Error("music session is disposed")
494
+ return next
495
+ }
496
+
497
+ return {
498
+ async player() {
499
+ await activeClient()
500
+ return project(latest)
501
+ },
502
+ async play() {
503
+ await (await activeClient()).play()
504
+ },
505
+ async pause() {
506
+ await (await activeClient()).pause()
507
+ },
508
+ async next() {
509
+ await (await activeClient()).next()
510
+ },
511
+ async previous() {
512
+ await (await activeClient()).previous()
513
+ },
514
+ async seek(positionMs) {
515
+ await (await activeClient()).seek(positionMs)
516
+ },
517
+ subscribe(listener) {
518
+ if (disposed) return () => {}
519
+ let closed = false
520
+ listeners.add(listener)
521
+ try {
522
+ if (latest) listener({ type: "snapshot", state: project(latest)! })
523
+ if (installed || acquisitionError) listener(lifecycle())
524
+ } catch {
525
+ // Replay is observer-isolated too.
526
+ }
527
+ return () => {
528
+ if (closed) return
529
+ closed = true
530
+ listeners.delete(listener)
531
+ }
532
+ },
533
+ subscribePresentation(listener) {
534
+ if (disposed) return () => {}
535
+ let closed = false
536
+ presentationListeners.add(listener)
537
+ return () => {
538
+ if (closed) return
539
+ closed = true
540
+ presentationListeners.delete(listener)
541
+ }
542
+ },
543
+ dispose() {
544
+ if (disposal) return disposal
545
+ disposed = true
546
+ for (const unsubscribe of unsubscribers.splice(0)) {
547
+ try {
548
+ unsubscribe()
549
+ } catch {}
550
+ }
551
+ listeners.clear()
552
+ presentationListeners.clear()
553
+ removeArtworkInterests(host)
554
+ disposal = clientPromise.then(
555
+ (next) => releaseClient(next),
556
+ () => undefined,
557
+ )
558
+ return disposal
559
+ },
560
+ }
561
+ }
562
+
563
+ export function openNowPlayingApp() {
564
+ Bun.spawn(["open", "https://music.youtube.com"], {
565
+ stdout: "ignore",
566
+ stderr: "ignore",
567
+ })
568
+ }
package/tmux-offset.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Tmux pane-origin offset for absolute Kitty coordinates.
3
+ *
4
+ * Cache is keyed by OpenTUI **pre-offset** slot geometry so resize/layout
5
+ * changes force a re-query even when wall-clock TTL would still allow a hit.
6
+ */
7
+
8
+ export type SlotGeometry = {
9
+ screenX: number
10
+ screenY: number
11
+ width: number
12
+ height: number
13
+ }
14
+
15
+ export type TmuxOffset = { x: number; y: number }
16
+
17
+ export type TmuxOffsetQueryResult = {
18
+ ok: boolean
19
+ stdout: string
20
+ }
21
+
22
+ export type TmuxOffsetCache = {
23
+ offset: TmuxOffset | null
24
+ slot: SlotGeometry | null
25
+ }
26
+
27
+ const ZERO: TmuxOffset = { x: 0, y: 0 }
28
+
29
+ export function createTmuxOffsetCache(): TmuxOffsetCache {
30
+ return { offset: null, slot: null }
31
+ }
32
+
33
+ /** True only when running under tmux without Herdr (Herdr already owns passthrough). */
34
+ export function shouldUseTmuxOffset(
35
+ env: NodeJS.ProcessEnv = process.env,
36
+ ): boolean {
37
+ return Boolean(env.TMUX) && !env.HERDR_ENV
38
+ }
39
+
40
+ function slotKey(slot: SlotGeometry): string {
41
+ return [slot.screenX, slot.screenY, slot.width, slot.height].join(":")
42
+ }
43
+
44
+ function sameSlotGeometry(
45
+ a: SlotGeometry | null,
46
+ b: SlotGeometry | null,
47
+ ): boolean {
48
+ if (a === null || b === null) return false
49
+ return slotKey(a) === slotKey(b)
50
+ }
51
+
52
+ /**
53
+ * Parse `tmux display-message -p '#{pane_left}\t#{pane_top}\t#{status-position}\t#{status}'`.
54
+ * Status bar on top adds one row when status is not off.
55
+ */
56
+ export function parseTmuxOffsetMessage(stdout: string): TmuxOffset {
57
+ const [left, top, statusPosition, status] = stdout.trim().split("\t")
58
+ return {
59
+ x: Number.parseInt(left ?? "0", 10) || 0,
60
+ y:
61
+ (Number.parseInt(top ?? "0", 10) || 0) +
62
+ (statusPosition === "top" && status !== "off" ? 1 : 0),
63
+ }
64
+ }
65
+
66
+ function queryTmuxOffset(): TmuxOffsetQueryResult {
67
+ const result = Bun.spawnSync([
68
+ "tmux",
69
+ "display-message",
70
+ "-p",
71
+ "#{pane_left}\t#{pane_top}\t#{status-position}\t#{status}",
72
+ ])
73
+ return {
74
+ ok: result.exitCode === 0,
75
+ stdout: result.stdout.toString(),
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Resolve pane origin for absolute Kitty placement.
81
+ *
82
+ * - No TMUX / HERDR_ENV → zeros (no spawn).
83
+ * - Slot geometry changed or first paint / empty cache → re-query.
84
+ * - Identical slot + prior successful cache → reuse (no spawn).
85
+ * - Failed query → zeros and clear cache so the next paint retries.
86
+ */
87
+ export function resolveTerminalOffset(options: {
88
+ env?: NodeJS.ProcessEnv
89
+ slot: SlotGeometry | null
90
+ cache: TmuxOffsetCache
91
+ query?: () => TmuxOffsetQueryResult
92
+ }): TmuxOffset {
93
+ const env = options.env ?? process.env
94
+ if (!shouldUseTmuxOffset(env)) {
95
+ options.cache.offset = null
96
+ options.cache.slot = null
97
+ return ZERO
98
+ }
99
+
100
+ if (
101
+ options.cache.offset !== null &&
102
+ options.slot !== null &&
103
+ sameSlotGeometry(options.cache.slot, options.slot)
104
+ ) {
105
+ return options.cache.offset
106
+ }
107
+
108
+ const query = options.query ?? queryTmuxOffset
109
+ const result = query()
110
+ if (!result.ok) {
111
+ options.cache.offset = null
112
+ options.cache.slot = null
113
+ return ZERO
114
+ }
115
+
116
+ const offset = parseTmuxOffsetMessage(result.stdout)
117
+ options.cache.offset = offset
118
+ options.cache.slot = options.slot ? { ...options.slot } : null
119
+ return offset
120
+ }