SoundCloud Now Playing widget

Now-playing card for the SoundCloud binding: the background follows the cover colours, long titles scroll, a search panel opens from the magnifier icon (tap a result to play it), the progress bar is seekable and the selected Chromecast is shown as a chip. The texts follow your openHAB language (German or English).

Requires the SoundCloud binding v1.6.0 or newer. Set the widget properties to the items linked to the binding channels (player#title, player#playback-state, player#track-id, search#query, search#results, player#chromecast-target, optionally player#volume, player#mute, …). Full item list and property table: see the binding post.

Screenshots

Changelog

Version 1.1

  • added: texts follow the openHAB language (German / English)
  • changed: English property labels; removed obsolete playlist properties

Version 1.0

  • initial release

Resources

uid: soundcloud_nowplaying
tags:
  - soundcloud
props:
  parameterGroups:
    - name: player
      label: Player
    - name: search
      label: Search
    - name: cast
      label: Chromecast
  parameters:
    - name: titleItem
      label: Title
      description: String item β†’ player#title
      type: TEXT
      context: item
      groupName: player
      required: true
    - name: artistItem
      label: Artist
      description: String item β†’ player#artist
      type: TEXT
      context: item
      groupName: player
    - name: artworkItem
      label: Cover URL
      description: String item β†’ player#artwork-url
      type: TEXT
      context: item
      groupName: player
    - name: stateItem
      label: Playback state
      description: String item β†’ player#playback-state
      type: TEXT
      context: item
      groupName: player
      required: true
    - name: trackIdItem
      label: Track ID
      description: String item β†’ player#track-id
      type: TEXT
      context: item
      groupName: player
      required: true
    - name: streamUrlItem
      label: Stream URL
      description: String item β†’ player#stream-url
      type: TEXT
      context: item
      groupName: player
    - name: elapsedItem
      label: Elapsed time
      description: Number item β†’ player#elapsed-time
      type: TEXT
      context: item
      groupName: player
    - name: durationItem
      label: Duration
      description: Number item β†’ player#duration
      type: TEXT
      context: item
      groupName: player
    - name: seekPctItem
      label: Seek (%)
      description: "Optional: Number item 0–100 to seek with the progress bar (needs the seek rule from the binding repo)"
      type: TEXT
      context: item
      groupName: player
    - name: queueIndexItem
      label: Queue position
      description: Number item β†’ player#queue-index
      type: TEXT
      context: item
      groupName: player
    - name: queueSizeItem
      label: Queue size
      description: Number item β†’ player#queue-size
      type: TEXT
      context: item
      groupName: player
    - name: shuffleItem
      label: Shuffle
      description: Switch item β†’ player#shuffle
      type: TEXT
      context: item
      groupName: player
    - name: repeatItem
      label: Repeat
      description: String item β†’ player#repeat (OFF / PLAYLIST / TRACK)
      type: TEXT
      context: item
      groupName: player
    - name: queryItem
      label: Search query
      description: String item β†’ search#query
      type: TEXT
      context: item
      groupName: search
      required: true
    - name: resultsItem
      label: Search results
      description: String item β†’ search#results
      type: TEXT
      context: item
      groupName: search
      required: true
    - name: targetItem
      label: Chromecast target
      description: String item β†’ player#chromecast-target
      type: TEXT
      context: item
      groupName: cast
      required: true
    - name: chromecastTag
      label: Chromecast tag
      description: Tag of your Chromecast playuri items (default Chromecast)
      type: TEXT
      default: Chromecast
      groupName: cast
    - name: volumeItem
      label: Volume
      description: Dimmer item β†’ player#volume
      type: TEXT
      context: item
      groupName: cast
    - name: muteItem
      label: Mute
      description: Switch item β†’ player#mute
      type: TEXT
      context: item
      groupName: cast
component: f7-card
config:
  style:
    position: relative
    overflow: clip
    border-radius: 18px
    margin: 0
    max-width: 960px
    margin-left: auto
    margin-right: auto
    background: "#222"
    color: white
  stylesheet: >
    @keyframes sc-marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
    .sc-mq { overflow: hidden; white-space: nowrap; width: 100%; }
    .sc-mq-run { display: inline-flex; animation-name: sc-marquee; animation-timing-function: linear; animation-iteration-count: infinite; }
    .sc-mq-run span { padding-right: 5em; }
    .sc-mq-static { display: block; text-align: center; overflow: hidden; text-overflow: ellipsis; }
    .sc-ctrl { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; color: white !important; }
    .sc-ctrl:hover { background: rgba(255,255,255,0.12); }
    .sc-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 10px; background: rgba(255,255,255,0.14); border: 1px solid rgba(255,255,255,0.25); color: white !important; font-size: 13px; }
    .sc-chip.sc-sel { background: rgba(255,255,255,0.85); color: #222 !important; }
    .sc-row { display: flex !important; align-items: center; gap: 12px; padding: 6px 4px; border-radius: 8px; color: white !important; text-align: left; }
    .sc-row:hover { background: rgba(255,255,255,0.12); }
    .sc-search input { width: 100%; color: white !important; caret-color: white; background: rgba(255,255,255,0.12) !important; border: 1px solid rgba(255,255,255,0.35) !important; border-radius: 10px; padding: 8px 12px; font-size: 15px; outline: none; }
    .sc-search input::placeholder { color: rgba(255,255,255,0.6); }
    .sc-seek { --f7-range-bar-height: 4px; --f7-range-knob-size: 12px; --f7-range-bar-active-bg-color: white; --f7-range-bar-bg-color: rgba(255,255,255,0.25); --f7-range-knob-color: white; }
slots:
  default:
    # Background: blurred cover
    - component: div
      config:
        style:
          position: absolute
          inset: -80px
          background-image: "=props.artworkItem && ('' + @@props.artworkItem).indexOf('http') === 0 ? 'url(' + @@props.artworkItem + ')' : 'none'"
          background-size: cover
          background-position: center
          filter: blur(70px) saturate(1.5)
          transform: scale(1.3)
    - component: div
      config:
        style:
          position: absolute
          inset: 0
          background: linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.30) 55%, rgba(0,0,0,0.55) 100%)
    - component: div
      config:
        style:
          position: relative
          z-index: 1
          display: flex
          flex-direction: column
          align-items: center
          padding: 16px 24px 20px
          gap: 10px
          text-shadow: 0 1px 6px rgba(0,0,0,0.45)
      slots:
        default:
          # Header
          - component: div
            config:
              style:
                display: flex
                width: 100%
                justify-content: space-between
                align-items: center
            slots:
              default:
                - component: div
                  config:
                    content: "=(@@props.stateItem === 'PLAYING' ? (dayjs.locale().indexOf('de') === 0 ? '● spielt' : '● playing') : (@@props.stateItem === 'PAUSED' ? (dayjs.locale().indexOf('de') === 0 ? '❚❚ pausiert' : '❚❚ paused') : (dayjs.locale().indexOf('de') === 0 ? 'β–  gestoppt' : 'β–  stopped'))) + (props.queueSizeItem && Number(@@props.queueSizeItem) > 1 ? '   Β·   ' + Number(@@props.queueIndexItem) + ' / ' + Number(@@props.queueSizeItem) : '')"
                    style:
                      font-size: 13px
                      opacity: 0.85
                      padding: 4px 10px
                      border-radius: 8px
                      background: rgba(0,0,0,0.25)
                      white-space: pre
                - component: div
                  config:
                    style:
                      display: flex
                      gap: 4px
                  slots:
                    default:
                      - component: oh-link
                        config:
                          class:
                            - sc-ctrl
                          iconF7: "=vars.showSearch ? 'xmark' : 'search'"
                          iconSize: 22
                          action: variable
                          actionVariable: showSearch
                          actionVariableValue: =!vars.showSearch
                      - component: oh-link
                        config:
                          class:
                            - sc-ctrl
                          iconF7: stop_fill
                          iconSize: 20
                          action: command
                          actionItem: =props.stateItem
                          actionCommand: STOP
          # Search
          - component: div
            config:
              visible: =vars.showSearch === true
              style:
                width: 100%
                background: rgba(0,0,0,0.35)
                border-radius: 14px
                padding: 10px
                box-sizing: border-box
            slots:
              default:
                - component: div
                  config:
                    style:
                      display: flex
                      align-items: center
                      gap: 6px
                  slots:
                    default:
                      - component: oh-input
                        config:
                          class:
                            - sc-search
                          item: =props.queryItem
                          type: text
                          placeholder: "=dayjs.locale().indexOf('de') === 0 ? 'SoundCloud durchsuchen …' : 'Search SoundCloud …'"
                          sendButton: true
                          clearButton: true
                          style:
                            flex: 1
                - component: div
                  config:
                    visible: "=('' + @@props.resultsItem) === '[]'"
                    content: "=dayjs.locale().indexOf('de') === 0 ? 'Keine Treffer' : 'No results'"
                    style:
                      padding: 10px 4px
                      opacity: 0.8
                - component: div
                  config:
                    style:
                      max-height: 320px
                      overflow-y: auto
                      margin-top: 6px
                  slots:
                    default:
                      - component: oh-repeater
                        config:
                          for: r
                          sourceType: array
                          in: "=('' + @@props.resultsItem).indexOf('[') === 0 ? JSON.parse(@@props.resultsItem) : []"
                          fragment: true
                        slots:
                          default:
                            - component: oh-link
                              config:
                                class:
                                  - sc-row
                                action: command
                                actionItem: =props.trackIdItem
                                actionCommand: ='' + loop.r.id
                              slots:
                                default:
                                  - component: div
                                    config:
                                      style:
                                        width: 44px
                                        height: 44px
                                        flex: none
                                        border-radius: 6px
                                        background-color: rgba(255,255,255,0.15)
                                        background-image: "=loop.r.artwork ? 'url(' + loop.r.artwork + ')' : 'none'"
                                        background-size: cover
                                        background-position: center
                                  - component: div
                                    config:
                                      style:
                                        flex: 1
                                        min-width: 0
                                    slots:
                                      default:
                                        - component: div
                                          config:
                                            content: =loop.r.title
                                            style:
                                              font-weight: 600
                                              white-space: nowrap
                                              overflow: hidden
                                              text-overflow: ellipsis
                                        - component: div
                                          config:
                                            content: "=(loop.r.artist || '') + (loop.r.duration ? '  Β·  ' + Math.floor(loop.r.duration / 60) + ' Min' : '')"
                                            style:
                                              font-size: 12px
                                              opacity: 0.75
                                              white-space: pre
                                              overflow: hidden
                                              text-overflow: ellipsis
                                  - component: f7-icon
                                    config:
                                      f7: play_circle
                                      size: 26
          # Cover
          - component: div
            config:
              style:
                width: min(400px, 75vw)
                aspect-ratio: 1 / 1
                border-radius: 12px
                box-shadow: 0 12px 40px rgba(0,0,0,0.45)
                background-color: rgba(255,255,255,0.08)
                background-image: "=props.artworkItem && ('' + @@props.artworkItem).indexOf('http') === 0 ? 'url(' + @@props.artworkItem + ')' : 'none'"
                background-size: cover
                background-position: center
                margin: 6px 0 10px
          # Title (scrolls when long)
          - component: div
            config:
              class:
                - sc-mq
              style:
                font-size: 28px
                font-weight: 700
            slots:
              default:
                - component: div
                  config:
                    class: "=('' + @@props.titleItem).length > 34 ? 'sc-mq-run' : 'sc-mq-static'"
                    style:
                      animation-duration: "=(('' + @@props.titleItem).length * 0.3) + 's'"
                  slots:
                    default:
                      - component: span
                        config:
                          content: "=['NULL', 'UNDEF', ''].includes('' + @@props.titleItem) ? (dayjs.locale().indexOf('de') === 0 ? 'Nichts wird abgespielt' : 'Nothing playing') : @@props.titleItem"
                      - component: span
                        config:
                          visible: "=('' + @@props.titleItem).length > 34"
                          content: =@@props.titleItem
          # Artist
          - component: div
            config:
              visible: "=props.artistItem && !['NULL', 'UNDEF', ''].includes('' + @@props.artistItem)"
              content: =@@props.artistItem
              style:
                font-size: 20px
                opacity: 0.8
                text-align: center
          # Progress
          - component: div
            config:
              visible: =!!props.durationItem
              style:
                width: 100%
                margin-top: 14px
            slots:
              default:
                - component: oh-slider
                  config:
                    visible: =!!props.seekPctItem
                    class:
                      - sc-seek
                    item: =props.seekPctItem
                    min: 0
                    max: 100
                    step: 1
                    releaseOnly: true
                    style:
                      width: 100%
                - component: div
                  config:
                    visible: =!props.seekPctItem
                    style:
                      height: 4px
                      width: 100%
                      border-radius: 2px
                      background: rgba(255,255,255,0.25)
                      overflow: hidden
                  slots:
                    default:
                      - component: div
                        config:
                          style:
                            height: 100%
                            background: white
                            transition: width 1s linear
                            width: "=Math.min(100, (Number(@@props.elapsedItem) || 0) / (Number(@@props.durationItem) || 1) * 100) + '%'"
                - component: div
                  config:
                    style:
                      display: flex
                      justify-content: space-between
                      font-size: 12px
                      opacity: 0.8
                      margin-top: 6px
                  slots:
                    default:
                      - component: span
                        config:
                          content: "=(Number(@@props.elapsedItem) >= 3600 ? Math.floor(Number(@@props.elapsedItem) / 3600) + ':' : '') + ('0' + Math.floor((Number(@@props.elapsedItem) || 0) % 3600 / 60)).slice(-2) + ':' + ('0' + Math.floor((Number(@@props.elapsedItem) || 0) % 60)).slice(-2)"
                      - component: span
                        config:
                          content: "=(Number(@@props.durationItem) >= 3600 ? Math.floor(Number(@@props.durationItem) / 3600) + ':' : '') + ('0' + Math.floor((Number(@@props.durationItem) || 0) % 3600 / 60)).slice(-2) + ':' + ('0' + Math.floor((Number(@@props.durationItem) || 0) % 60)).slice(-2)"
          # Controls
          - component: div
            config:
              style:
                display: flex
                width: 100%
                justify-content: space-around
                align-items: center
                margin-top: 4px
            slots:
              default:
                - component: oh-link
                  config:
                    class:
                      - sc-ctrl
                    iconF7: shuffle
                    iconSize: 22
                    action: toggle
                    actionItem: =props.shuffleItem
                    actionCommand: "ON"
                    actionCommandAlt: "OFF"
                    style:
                      opacity: "=props.shuffleItem && @@props.shuffleItem === 'ON' ? 1 : 0.55"
                - component: oh-link
                  config:
                    class:
                      - sc-ctrl
                    iconF7: backward_end
                    iconSize: 26
                    action: command
                    actionItem: =props.stateItem
                    actionCommand: PREVIOUS
                # Play / Pause (the binding controls the Chromecast)
                - component: oh-link
                  config:
                    iconF7: "=@@props.stateItem === 'PLAYING' ? 'pause_fill' : 'play_fill'"
                    iconSize: 30
                    action: command
                    actionItem: =props.stateItem
                    actionCommand: "=@@props.stateItem === 'PLAYING' ? 'PAUSE' : 'PLAY'"
                    style:
                      display: flex
                      align-items: center
                      justify-content: center
                      width: 60px
                      height: 60px
                      border-radius: 50%
                      background: rgba(255,255,255,0.92)
                      color: "#222"
                      box-shadow: 0 4px 16px rgba(0,0,0,0.3)
                - component: oh-link
                  config:
                    class:
                      - sc-ctrl
                    iconF7: forward_end
                    iconSize: 26
                    action: command
                    actionItem: =props.stateItem
                    actionCommand: NEXT
                - component: oh-link
                  config:
                    class:
                      - sc-ctrl
                    iconF7: "=props.repeatItem && @@props.repeatItem === 'TRACK' ? 'repeat_1' : 'repeat'"
                    iconSize: 22
                    action: command
                    actionItem: =props.repeatItem
                    actionCommand: "=@@props.repeatItem === 'PLAYLIST' ? 'TRACK' : (@@props.repeatItem === 'TRACK' ? 'OFF' : 'PLAYLIST')"
                    style:
                      opacity: "=props.repeatItem && ['PLAYLIST', 'TRACK'].includes(@@props.repeatItem) ? 1 : 0.55"
          # Volume (selected Chromecast via player#volume / player#mute)
          - component: div
            config:
              style:
                display: flex
                width: 100%
                align-items: center
                gap: 10px
            slots:
              default:
                - component: oh-link
                  config:
                    class:
                      - sc-ctrl
                    iconF7: "=@@props.muteItem === 'ON' ? 'speaker_slash' : 'speaker_2'"
                    iconSize: 20
                    action: toggle
                    actionItem: =props.muteItem
                    actionCommand: "ON"
                    actionCommandAlt: "OFF"
                - component: oh-slider
                  config:
                    item: =props.volumeItem
                    min: 0
                    max: 100
                    step: 1
                    releaseOnly: true
                    style:
                      flex: 1
                      --f7-range-bar-active-bg-color: white
                      --f7-range-bar-bg-color: rgba(255,255,255,0.3)
                      --f7-range-knob-color: white
                - component: span
                  config:
                    content: "=Number(@@props.volumeItem) >= 0 ? Math.round(Number(@@props.volumeItem)) : '-'"
                    style:
                      width: 30px
                      text-align: right
                      font-size: 13px
          # Selected Chromecast (tap to open the device list)
          - component: oh-repeater
            config:
              for: c
              sourceType: itemsWithTags
              itemTags: =props.chromecastTag || 'Chromecast'
              filter: loop.c.name === @@props.targetItem
              fragment: true
            slots:
              default:
                - component: oh-link
                  config:
                    class:
                      - sc-chip
                    iconF7: "=vars.showCast ? 'chevron_down' : 'tv_music_note'"
                    iconSize: 16
                    text: =loop.c.label
                    action: variable
                    actionVariable: showCast
                    actionVariableValue: =!vars.showCast
          - component: oh-link
            config:
              visible: "=['NULL', 'UNDEF', ''].includes('' + @@props.targetItem)"
              class:
                - sc-chip
              iconF7: tv_music_note
              iconSize: 16
              text: "=dayjs.locale().indexOf('de') === 0 ? 'Chromecast wΓ€hlen' : 'Select Chromecast'"
              action: variable
              actionVariable: showCast
              actionVariableValue: =!vars.showCast
          - component: div
            config:
              visible: =vars.showCast === true
              style:
                display: flex
                flex-wrap: wrap
                justify-content: center
                gap: 8px
                padding-top: 6px
            slots:
              default:
                - component: oh-repeater
                  config:
                    for: c
                    sourceType: itemsWithTags
                    itemTags: =props.chromecastTag || 'Chromecast'
                    fragment: true
                  slots:
                    default:
                      - component: oh-link
                        config:
                          class: "=loop.c.name === @@props.targetItem ? 'sc-chip sc-sel' : 'sc-chip'"
                          text: =loop.c.label
                          action: command
                          actionItem: =props.targetItem
                          actionCommand: =loop.c.name
1 Like